@j0hanz/filesystem-mcp 1.3.1 → 1.3.2

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.
@@ -1,3 +1,6 @@
1
+ declare const VALID_LOG_LEVELS: readonly ["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"];
2
+ export type ValidLogLevel = (typeof VALID_LOG_LEVELS)[number];
3
+ export declare const DEFAULT_LOG_LEVEL: "debug" | "info" | "notice" | "warning" | "error" | "critical" | "alert" | "emergency";
1
4
  export declare const PARALLEL_CONCURRENCY: number;
2
5
  export declare const MAX_SEARCHABLE_FILE_SIZE: number;
3
6
  export declare const MAX_TEXT_FILE_SIZE: number;
@@ -19,3 +22,4 @@ export declare const SENSITIVE_FILE_ALLOWLIST: string[];
19
22
  export declare const KNOWN_BINARY_EXTENSIONS: Set<string>;
20
23
  export declare const DEFAULT_EXCLUDE_PATTERNS: string[];
21
24
  export declare function getMimeType(ext: string): string;
25
+ export {};
@@ -43,6 +43,28 @@ function parseEnvList(envVar) {
43
43
  }
44
44
  return entries;
45
45
  }
46
+ const VALID_LOG_LEVELS = [
47
+ 'debug',
48
+ 'info',
49
+ 'notice',
50
+ 'warning',
51
+ 'error',
52
+ 'critical',
53
+ 'alert',
54
+ 'emergency',
55
+ ];
56
+ function parseEnvLogLevel(envVar, defaultValue) {
57
+ const value = process.env[envVar];
58
+ if (!value)
59
+ return defaultValue;
60
+ const normalized = value.trim().toLowerCase();
61
+ if (VALID_LOG_LEVELS.includes(normalized)) {
62
+ return normalized;
63
+ }
64
+ console.error(`[WARNING] Invalid ${envVar} value: ${value} (must be ${VALID_LOG_LEVELS.join('|')}). Using default: ${defaultValue}`);
65
+ return defaultValue;
66
+ }
67
+ export const DEFAULT_LOG_LEVEL = parseEnvLogLevel('FILESYSTEM_MCP_LOG_LEVEL', 'debug');
46
68
  // Auto-tuned parallelism based on CPU cores (no env override)
47
69
  const BYTES_PER_PARALLEL_TASK = 64 * MIB;
48
70
  const BYTES_PER_SEARCH_WORKER = 128 * MIB;
@@ -2,6 +2,7 @@ import type { FileInfo, GetMultipleFileInfoResult } from '../../config.js';
2
2
  interface FileInfoOptions {
3
3
  includeMimeType?: boolean | undefined;
4
4
  signal?: AbortSignal | undefined;
5
+ onProgress?: () => void;
5
6
  }
6
7
  export declare function getFileInfo(filePath: string, options?: FileInfoOptions): Promise<FileInfo>;
7
8
  type GetMultipleFileInfoOptions = FileInfoOptions;
@@ -89,10 +89,11 @@ function buildIndexedPathTasks(paths) {
89
89
  return tasks;
90
90
  }
91
91
  async function readFileInfoInParallel(paths, options) {
92
- return processInParallel(buildIndexedPathTasks(paths), async ({ filePath, index }) => ({
93
- index,
94
- value: await processFileInfo(filePath, options),
95
- }), PARALLEL_CONCURRENCY, options.signal);
92
+ return processInParallel(buildIndexedPathTasks(paths), async ({ filePath, index }) => {
93
+ const value = await processFileInfo(filePath, options);
94
+ options.onProgress?.();
95
+ return { index, value };
96
+ }, PARALLEL_CONCURRENCY, options.signal);
96
97
  }
97
98
  function applyResults(output, results) {
98
99
  for (const result of results) {
@@ -19,6 +19,7 @@ interface ReadMultipleOptions {
19
19
  startLine?: number;
20
20
  endLine?: number;
21
21
  signal?: AbortSignal;
22
+ onReadComplete?: () => void;
22
23
  }
23
24
  export declare function readMultipleFiles(filePaths: readonly string[], options?: ReadMultipleOptions): Promise<ReadMultipleResult[]>;
24
25
  export {};
@@ -48,12 +48,16 @@ async function readSingleFile(task, readOptions) {
48
48
  value: buildReadMultipleResult(filePath, result),
49
49
  };
50
50
  }
51
- async function readFilesInParallel(filesToProcess, options, signal) {
51
+ async function readFilesInParallel(filesToProcess, options, signal, onReadComplete) {
52
52
  const readOptions = buildReadOptions(options);
53
53
  if (signal) {
54
54
  readOptions.signal = signal;
55
55
  }
56
- return processInParallel(filesToProcess, async (task) => readSingleFile(task, readOptions), PARALLEL_CONCURRENCY, signal);
56
+ return processInParallel(filesToProcess, async (task) => {
57
+ const result = await readSingleFile(task, readOptions);
58
+ onReadComplete?.();
59
+ return result;
60
+ }, PARALLEL_CONCURRENCY, signal);
57
61
  }
58
62
  function normalizeReadMultipleOptions(options) {
59
63
  const normalized = {
@@ -248,7 +252,7 @@ export async function readMultipleFiles(filePaths, options = {}) {
248
252
  const output = buildOutput(filePaths);
249
253
  const { skippedBudget, validated } = await collectFileBudget(filePaths, normalized.maxTotalSize, normalized.maxSize, signal);
250
254
  const filesToProcess = buildFilesToProcess(filePaths, validated, skippedBudget);
251
- const { results, errors } = await readFilesInParallel(filesToProcess, normalized, signal);
255
+ const { results, errors } = await readFilesInParallel(filesToProcess, normalized, signal, options.onReadComplete);
252
256
  applyResults(output, results);
253
257
  applyErrors(output, errors, filesToProcess, filePaths);
254
258
  applySkippedBudget(output, skippedBudget, filePaths, normalized.maxTotalSize);
@@ -12,6 +12,10 @@ interface TreeOptions {
12
12
  includeIgnored?: boolean;
13
13
  timeoutMs?: number;
14
14
  signal?: AbortSignal;
15
+ onProgress?: (progress: {
16
+ total?: number;
17
+ current: number;
18
+ }) => void;
15
19
  }
16
20
  interface TreeResult {
17
21
  root: string;
@@ -265,6 +265,7 @@ export async function treeDirectory(dirPath, options = {}) {
265
265
  const parent = ensureParentNodes(rootNode, nodeByPath, resolved.relativePosix);
266
266
  upsertChildNode(parent, nodeByPath, resolved, childPathIndexByParent);
267
267
  totalEntries += 1;
268
+ options.onProgress?.({ current: totalEntries });
268
269
  }
269
270
  sortTree(rootNode);
270
271
  return {
package/dist/schemas.d.ts CHANGED
@@ -733,6 +733,7 @@ export declare const SearchAndReplaceOutputSchema: z.ZodObject<{
733
733
  matches: z.ZodNumber;
734
734
  }, z.core.$strict>>>;
735
735
  changedFilesTruncated: z.ZodOptional<z.ZodBoolean>;
736
+ diff: z.ZodOptional<z.ZodString>;
736
737
  dryRun: z.ZodOptional<z.ZodBoolean>;
737
738
  error: z.ZodOptional<z.ZodObject<{
738
739
  code: z.ZodEnum<{
package/dist/schemas.js CHANGED
@@ -662,6 +662,7 @@ export const SearchAndReplaceOutputSchema = z.strictObject({
662
662
  .boolean()
663
663
  .optional()
664
664
  .describe('Changed file list truncated'),
665
+ diff: z.string().optional().describe('Unified diff of changes (dryRun only)'),
665
666
  dryRun: z.boolean().optional(),
666
667
  error: ErrorSchema.optional(),
667
668
  });
@@ -7,6 +7,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
7
7
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
8
  import { isInitializeRequest, SetLevelRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
9
9
  import { registerCompletions } from '../completions.js';
10
+ import { DEFAULT_LOG_LEVEL } from '../lib/constants.js';
10
11
  import { formatUnknownErrorMessage } from '../lib/errors.js';
11
12
  import { createInMemoryResourceStore } from '../lib/resource-store.js';
12
13
  import { pkgInfo } from '../pkg-info.js';
@@ -77,7 +78,7 @@ export async function createServer(options = {}) {
77
78
  ...(SERVER_DESCRIPTION ? { description: SERVER_DESCRIPTION } : {}),
78
79
  ...(SERVER_HOMEPAGE ? { websiteUrl: SERVER_HOMEPAGE } : {}),
79
80
  }, localIcon), serverConfig);
80
- const loggingState = createLoggingState('debug');
81
+ const loggingState = createLoggingState(DEFAULT_LOG_LEVEL);
81
82
  const rootsManager = new RootsManager(options, loggingState);
82
83
  rootsManagers.set(server, rootsManager);
83
84
  server.server.setRequestHandler(SetLevelRequestSchema, (req) => {
@@ -160,6 +161,14 @@ async function createHttpSession(options, sessions) {
160
161
  await mcpServer.connect(transport);
161
162
  return { server: mcpServer, transport };
162
163
  }
164
+ function sendJsonRpcError(res, status, code, message) {
165
+ res.writeHead(status, { 'Content-Type': 'application/json' });
166
+ res.end(JSON.stringify({
167
+ jsonrpc: '2.0',
168
+ error: { code, message },
169
+ id: null,
170
+ }));
171
+ }
163
172
  export async function startHttpServer(port, options) {
164
173
  const sessions = new Map();
165
174
  async function handleMcpRequest(req, res) {
@@ -198,91 +207,27 @@ export async function startHttpServer(port, options) {
198
207
  if (session) {
199
208
  await session.transport.handleRequest(req, res, body);
200
209
  }
201
- else {
202
- res.writeHead(400, { 'Content-Type': 'application/json' });
203
- res.end(JSON.stringify({
204
- jsonrpc: '2.0',
205
- error: {
206
- code: -32000,
207
- message: 'Bad Request: Session not found',
208
- },
209
- id: null,
210
- }));
211
- }
212
210
  }
213
211
  else if (!sessionId && isInitializeRequest(body)) {
214
212
  const { transport } = await createHttpSession(options, sessions);
215
213
  await transport.handleRequest(req, res, body);
216
214
  }
217
- else {
218
- res.writeHead(400, { 'Content-Type': 'application/json' });
219
- res.end(JSON.stringify({
220
- jsonrpc: '2.0',
221
- error: {
222
- code: -32000,
223
- message: 'Bad Request: No valid session ID provided',
224
- },
225
- id: null,
226
- }));
227
- }
228
- }
229
- else if (method === 'GET') {
230
- if (!sessionId || !sessions.has(sessionId)) {
231
- res.writeHead(400, { 'Content-Type': 'application/json' });
232
- res.end(JSON.stringify({
233
- jsonrpc: '2.0',
234
- error: {
235
- code: -32000,
236
- message: 'Bad Request: Invalid or missing session ID',
237
- },
238
- id: null,
239
- }));
240
- return;
241
- }
242
- const session = sessions.get(sessionId);
243
- if (session) {
244
- await session.transport.handleRequest(req, res);
215
+ else if (sessionId) {
216
+ sendJsonRpcError(res, 400, -32000, 'Bad Request: Session not found');
245
217
  }
246
218
  else {
247
- res.writeHead(400, { 'Content-Type': 'application/json' });
248
- res.end(JSON.stringify({
249
- jsonrpc: '2.0',
250
- error: {
251
- code: -32000,
252
- message: 'Bad Request: Session not found',
253
- },
254
- id: null,
255
- }));
219
+ sendJsonRpcError(res, 400, -32000, 'Bad Request: No valid session ID provided');
256
220
  }
257
221
  }
258
- else if (method === 'DELETE') {
222
+ else if (method === 'GET' || method === 'DELETE') {
259
223
  if (!sessionId || !sessions.has(sessionId)) {
260
- res.writeHead(400, { 'Content-Type': 'application/json' });
261
- res.end(JSON.stringify({
262
- jsonrpc: '2.0',
263
- error: {
264
- code: -32000,
265
- message: 'Bad Request: Invalid or missing session ID',
266
- },
267
- id: null,
268
- }));
224
+ sendJsonRpcError(res, 400, -32000, 'Bad Request: Invalid or missing session ID');
269
225
  return;
270
226
  }
271
227
  const session = sessions.get(sessionId);
272
228
  if (session) {
273
229
  await session.transport.handleRequest(req, res);
274
230
  }
275
- else {
276
- res.writeHead(400, { 'Content-Type': 'application/json' });
277
- res.end(JSON.stringify({
278
- jsonrpc: '2.0',
279
- error: {
280
- code: -32000,
281
- message: 'Bad Request: Session not found',
282
- },
283
- id: null,
284
- }));
285
- }
286
231
  }
287
232
  else {
288
233
  res.writeHead(405, { Allow: 'GET, POST, DELETE' });
@@ -292,12 +237,7 @@ export async function startHttpServer(port, options) {
292
237
  catch (error) {
293
238
  console.error('[HTTP] Error handling request:', formatUnknownErrorMessage(error));
294
239
  if (!res.headersSent) {
295
- res.writeHead(500, { 'Content-Type': 'application/json' });
296
- res.end(JSON.stringify({
297
- jsonrpc: '2.0',
298
- error: { code: -32603, message: 'Internal Server Error' },
299
- id: null,
300
- }));
240
+ sendJsonRpcError(res, 500, -32603, 'Internal Server Error');
301
241
  }
302
242
  }
303
243
  }
@@ -7,6 +7,7 @@ import { withAbort } from '../lib/fs-helpers.js';
7
7
  import { validateExistingPath } from '../lib/path-validation.js';
8
8
  import { DiffFilesInputSchema, DiffFilesOutputSchema } from '../schemas.js';
9
9
  import { buildResourceLink, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
10
+ import { registerToolTaskIfAvailable } from './task-support.js';
10
11
  export const DIFF_FILES_TOOL = {
11
12
  name: 'diff_files',
12
13
  title: 'Diff Files',
@@ -104,5 +105,7 @@ export function registerDiffFilesTool(server, options = {}) {
104
105
  },
105
106
  });
106
107
  const validatedHandler = withValidatedArgs(DiffFilesInputSchema, wrappedHandler);
108
+ if (registerToolTaskIfAvailable(server, 'diff_files', DIFF_FILES_TOOL, validatedHandler, options.iconInfo, options.isInitialized))
109
+ return;
107
110
  server.registerTool('diff_files', withDefaultIcons({ ...DIFF_FILES_TOOL }, options.iconInfo), validatedHandler);
108
111
  }
@@ -5,6 +5,7 @@ import { atomicWriteFile } from '../lib/fs-helpers.js';
5
5
  import { validateExistingPath } from '../lib/path-validation.js';
6
6
  import { EditFileInputSchema, EditFileOutputSchema } from '../schemas.js';
7
7
  import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
+ import { registerToolTaskIfAvailable } from './task-support.js';
8
9
  export const EDIT_FILE_TOOL = {
9
10
  name: 'edit',
10
11
  title: 'Edit File',
@@ -111,5 +112,7 @@ export function registerEditFileTool(server, options = {}) {
111
112
  },
112
113
  });
113
114
  const validatedHandler = withValidatedArgs(EditFileInputSchema, wrappedHandler);
115
+ if (registerToolTaskIfAvailable(server, 'edit', EDIT_FILE_TOOL, validatedHandler, options.iconInfo, options.isInitialized))
116
+ return;
114
117
  server.registerTool('edit', withDefaultIcons({ ...EDIT_FILE_TOOL }, options.iconInfo), validatedHandler);
115
118
  }
@@ -5,6 +5,7 @@ import { ErrorCode } from '../lib/errors.js';
5
5
  import { listDirectory } from '../lib/file-operations/list-directory.js';
6
6
  import { ListDirectoryInputSchema, ListDirectoryOutputSchema, } from '../schemas.js';
7
7
  import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
+ import { registerToolTaskIfAvailable } from './task-support.js';
8
9
  export const LIST_DIRECTORY_TOOL = {
9
10
  name: 'ls',
10
11
  title: 'List Directory',
@@ -144,5 +145,7 @@ export function registerListDirectoryTool(server, options = {}) {
144
145
  },
145
146
  });
146
147
  const validatedHandler = withValidatedArgs(ListDirectoryInputSchema, wrappedHandler);
148
+ if (registerToolTaskIfAvailable(server, 'ls', LIST_DIRECTORY_TOOL, validatedHandler, options.iconInfo, options.isInitialized))
149
+ return;
147
150
  server.registerTool('ls', withDefaultIcons({ ...LIST_DIRECTORY_TOOL }, options.iconInfo), validatedHandler);
148
151
  }
@@ -3,7 +3,7 @@ import { DEFAULT_READ_MANY_MAX_TOTAL_SIZE, DEFAULT_SEARCH_TIMEOUT_MS, } from '..
3
3
  import { ErrorCode } from '../lib/errors.js';
4
4
  import { readMultipleFiles } from '../lib/file-operations/read-multiple-files.js';
5
5
  import { ReadMultipleFilesInputSchema, ReadMultipleFilesOutputSchema, } from '../schemas.js';
6
- import { buildResourceLink, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
6
+ import { buildResourceLink, buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, maybeExternalizeTextContent, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
7
  import { registerToolTaskIfAvailable } from './task-support.js';
8
8
  export const READ_MULTIPLE_FILES_TOOL = {
9
9
  name: 'read_many',
@@ -19,12 +19,13 @@ export const READ_MULTIPLE_FILES_TOOL = {
19
19
  'Per-file `truncationReason` can be `head`, `range`, or `externalized`.',
20
20
  ],
21
21
  };
22
- async function handleReadMultipleFiles(args, signal, resourceStore) {
22
+ async function handleReadMultipleFiles(args, signal, resourceStore, onReadComplete) {
23
23
  const options = {
24
24
  ...(signal ? { signal } : {}),
25
25
  ...(args.head !== undefined ? { head: args.head } : {}),
26
26
  ...(args.startLine !== undefined ? { startLine: args.startLine } : {}),
27
27
  ...(args.endLine !== undefined ? { endLine: args.endLine } : {}),
28
+ ...(onReadComplete ? { onReadComplete } : {}),
28
29
  };
29
30
  const results = await readMultipleFiles(args.paths, options);
30
31
  const maxTotalSize = DEFAULT_READ_MANY_MAX_TOTAL_SIZE;
@@ -122,30 +123,61 @@ export function registerReadMultipleFilesTool(server, options = {}) {
122
123
  extra,
123
124
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
124
125
  context: { path: primaryPath },
125
- run: (signal) => handleReadMultipleFiles(args, signal, options.resourceStore),
126
+ run: async (signal) => {
127
+ const first = path.basename(args.paths[0] ?? '');
128
+ const extraPaths = args.paths.length > 1
129
+ ? `, ${path.basename(args.paths[1] ?? '')}${args.paths.length > 2 ? '…' : ''}`
130
+ : '';
131
+ const context = `${args.paths.length} files [${first}${extraPaths}]`;
132
+ let progressCursor = 0;
133
+ notifyProgress(extra, {
134
+ current: 0,
135
+ message: `🕮 read_many: ${context}`,
136
+ });
137
+ const baseReporter = createProgressReporter(extra);
138
+ const onReadComplete = () => {
139
+ progressCursor++;
140
+ baseReporter({
141
+ current: progressCursor,
142
+ message: `🕮 read_many: ${context} [${progressCursor}/${args.paths.length} read]`,
143
+ });
144
+ };
145
+ try {
146
+ const result = await handleReadMultipleFiles(args, signal, options.resourceStore, onReadComplete);
147
+ const sc = result.structuredContent;
148
+ const total = sc.summary?.total ?? 0;
149
+ const failed = sc.summary?.failed ?? 0;
150
+ const succeeded = sc.summary?.succeeded ?? 0;
151
+ let suffix;
152
+ if (failed) {
153
+ suffix = `${succeeded}/${total} read, ${failed} failed`;
154
+ }
155
+ else {
156
+ suffix = `${total} files read`;
157
+ }
158
+ const finalCurrent = Math.max(total, progressCursor + 1);
159
+ notifyProgress(extra, {
160
+ current: finalCurrent,
161
+ total: finalCurrent,
162
+ message: `🕮 read_many: ${context} • ${suffix}`,
163
+ });
164
+ return result;
165
+ }
166
+ catch (error) {
167
+ const finalCurrent = Math.max(progressCursor + 1, 1);
168
+ notifyProgress(extra, {
169
+ current: finalCurrent,
170
+ total: finalCurrent,
171
+ message: `🕮 read_many: ${context} • failed`,
172
+ });
173
+ throw error;
174
+ }
175
+ },
126
176
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FILE, primaryPath),
127
177
  });
128
178
  };
129
179
  const wrappedHandler = wrapToolHandler(handler, {
130
180
  guard: options.isInitialized,
131
- progressMessage: (args) => {
132
- const first = path.basename(args.paths[0] ?? '');
133
- const extra = args.paths.length > 1 ? `, ${path.basename(args.paths[1] ?? '')}…` : '';
134
- return `🕮 read_many: ${args.paths.length} files [${first}${extra}]`;
135
- },
136
- completionMessage: (args, result) => {
137
- if (result.isError)
138
- return `🕮 read_many: ${args.paths.length} files • failed`;
139
- const sc = result.structuredContent;
140
- if (!sc.ok)
141
- return `🕮 read_many: ${args.paths.length} files • failed`;
142
- const total = sc.summary?.total ?? 0;
143
- const succeeded = sc.summary?.succeeded ?? 0;
144
- const failed = sc.summary?.failed ?? 0;
145
- if (failed)
146
- return `🕮 read_many: ${succeeded}/${total} read, ${failed} failed`;
147
- return `🕮 read_many: ${total} files read`;
148
- },
149
181
  });
150
182
  const validatedHandler = withValidatedArgs(ReadMultipleFilesInputSchema, wrappedHandler);
151
183
  if (registerToolTaskIfAvailable(server, 'read_many', READ_MULTIPLE_FILES_TOOL, validatedHandler, options.iconInfo, options.isInitialized))
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
+ import { createTwoFilesPatch } from 'diff';
3
4
  import RE2 from 're2';
4
5
  import safeRegex from 'safe-regex2';
5
6
  import { DEFAULT_EXCLUDE_PATTERNS, MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY, } from '../lib/constants.js';
@@ -17,6 +18,7 @@ export const SEARCH_AND_REPLACE_TOOL = {
17
18
  'Replaces ALL occurrences in each file (unlike `edit` which replaces only the first). ' +
18
19
  'Use `filePattern` to scope which files are touched. ' +
19
20
  'Always run with `dryRun: true` first to verify matches before writing. ' +
21
+ 'Returns a unified diff of changes in `dryRun` mode. ' +
20
22
  'Literal mode (default) matches exact text; `isRegex: true` enables RE2 regex with capture groups ($1, $2).',
21
23
  inputSchema: SearchAndReplaceInputSchema,
22
24
  outputSchema: SearchAndReplaceOutputSchema,
@@ -31,6 +33,7 @@ export const SEARCH_AND_REPLACE_TOOL = {
31
33
  const MAX_FAILURES = 20;
32
34
  const REPLACE_CONCURRENCY = Math.min(PARALLEL_CONCURRENCY, 8);
33
35
  const MAX_CHANGED_FILES = 100;
36
+ const MAX_DIFF_SIZE = 20 * 1024; // 20KB limit for diff output
34
37
  function recordFailure(failures, failure) {
35
38
  if (failures.length >= MAX_FAILURES)
36
39
  return;
@@ -110,15 +113,22 @@ async function processEntry(entryPath, args, regex, maxFileSize, signal, summary
110
113
  summary.totalMatches += matchCount;
111
114
  summary.filesChanged++;
112
115
  recordChangedFile(summary, validPath, matchCount);
113
- if (!args.dryRun) {
114
- let newContent;
115
- if (args.isRegex && regex) {
116
- regex.lastIndex = 0;
117
- newContent = content.replace(regex, args.replacement);
118
- }
119
- else {
120
- newContent = content.replaceAll(args.searchPattern, () => args.replacement);
116
+ let newContent;
117
+ if (args.isRegex && regex) {
118
+ regex.lastIndex = 0;
119
+ newContent = content.replace(regex, args.replacement);
120
+ }
121
+ else {
122
+ newContent = content.replaceAll(args.searchPattern, () => args.replacement);
123
+ }
124
+ if (args.dryRun && summary.diff.length < MAX_DIFF_SIZE) {
125
+ const patch = createTwoFilesPatch(path.basename(validPath), path.basename(validPath), content, newContent, 'Original', 'Modified');
126
+ // Only append if it won't exceed the limit too much
127
+ if (summary.diff.length + patch.length <= MAX_DIFF_SIZE + 1024) {
128
+ summary.diff += patch;
121
129
  }
130
+ }
131
+ if (!args.dryRun) {
122
132
  await atomicWriteFile(validPath, newContent, {
123
133
  encoding: 'utf-8',
124
134
  signal,
@@ -167,6 +177,7 @@ function createReplaceSummary(root) {
167
177
  failures: [],
168
178
  changedFiles: [],
169
179
  changedFilesTruncated: false,
180
+ diff: '',
170
181
  };
171
182
  }
172
183
  async function resolveSearchRoot(pathValue, signal) {
@@ -228,6 +239,7 @@ async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
228
239
  ? { changedFiles: summary.changedFiles }
229
240
  : {}),
230
241
  ...(summary.changedFilesTruncated ? { changedFilesTruncated: true } : {}),
242
+ ...(args.dryRun && summary.diff ? { diff: summary.diff } : {}),
231
243
  dryRun: args.dryRun,
232
244
  });
233
245
  }
@@ -3,6 +3,7 @@ import { ErrorCode } from '../lib/errors.js';
3
3
  import { getAllowedDirectories } from '../lib/path-validation.js';
4
4
  import { ListAllowedDirectoriesInputSchema, ListAllowedDirectoriesOutputSchema, } from '../schemas.js';
5
5
  import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
6
+ import { registerToolTaskIfAvailable } from './task-support.js';
6
7
  export const LIST_ALLOWED_DIRECTORIES_TOOL = {
7
8
  name: 'roots',
8
9
  title: 'Workspace Roots',
@@ -54,5 +55,7 @@ export function registerListAllowedDirectoriesTool(server, options = {}) {
54
55
  },
55
56
  });
56
57
  const validatedHandler = withValidatedArgs(ListAllowedDirectoriesInputSchema, wrappedHandler);
58
+ if (registerToolTaskIfAvailable(server, 'roots', LIST_ALLOWED_DIRECTORIES_TOOL, validatedHandler, options.iconInfo, options.isInitialized))
59
+ return;
57
60
  server.registerTool('roots', withDefaultIcons({ ...LIST_ALLOWED_DIRECTORIES_TOOL }, options.iconInfo), validatedHandler);
58
61
  }
@@ -4,7 +4,7 @@ import { DEFAULT_SEARCH_TIMEOUT_MS } from '../lib/constants.js';
4
4
  import { ErrorCode } from '../lib/errors.js';
5
5
  import { getMultipleFileInfo } from '../lib/file-operations/file-info.js';
6
6
  import { GetMultipleFileInfoInputSchema, GetMultipleFileInfoOutputSchema, } from '../schemas.js';
7
- import { buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
+ import { buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
8
  import { registerToolTaskIfAvailable } from './task-support.js';
9
9
  export const GET_MULTIPLE_FILE_INFO_TOOL = {
10
10
  name: 'stat_many',
@@ -28,10 +28,11 @@ function formatFileInfoDetail(info) {
28
28
  lines.push(` Target: ${info.symlinkTarget}`);
29
29
  return joinLines(lines);
30
30
  }
31
- async function handleGetMultipleFileInfo(args, signal) {
31
+ async function handleGetMultipleFileInfo(args, signal, onProgress) {
32
32
  const result = await getMultipleFileInfo(args.paths, {
33
33
  includeMimeType: true,
34
34
  ...(signal ? { signal } : {}),
35
+ ...(onProgress ? { onProgress } : {}),
35
36
  });
36
37
  const structuredResults = [];
37
38
  const textBlocks = [];
@@ -71,30 +72,61 @@ export function registerGetMultipleFileInfoTool(server, options = {}) {
71
72
  extra,
72
73
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
73
74
  context: { path: primaryPath },
74
- run: (signal) => handleGetMultipleFileInfo(args, signal),
75
+ run: async (signal) => {
76
+ const first = path.basename(args.paths[0] ?? '');
77
+ const extraPaths = args.paths.length > 1
78
+ ? `, ${path.basename(args.paths[1] ?? '')}${args.paths.length > 2 ? '…' : ''}`
79
+ : '';
80
+ const context = `${args.paths.length} paths [${first}${extraPaths}]`;
81
+ let progressCursor = 0;
82
+ notifyProgress(extra, {
83
+ current: 0,
84
+ message: `🕮 stat_many: ${context}`,
85
+ });
86
+ const baseReporter = createProgressReporter(extra);
87
+ const onProgress = () => {
88
+ progressCursor++;
89
+ baseReporter({
90
+ current: progressCursor,
91
+ message: `🕮 stat_many: ${context} [${progressCursor}/${args.paths.length} scanned]`,
92
+ });
93
+ };
94
+ try {
95
+ const result = await handleGetMultipleFileInfo(args, signal, onProgress);
96
+ const sc = result.structuredContent;
97
+ const total = sc.summary?.total ?? 0;
98
+ const failed = sc.summary?.failed ?? 0;
99
+ const succeeded = sc.summary?.succeeded ?? 0;
100
+ let suffix;
101
+ if (failed) {
102
+ suffix = `${succeeded}/${total} OK, ${failed} failed`;
103
+ }
104
+ else {
105
+ suffix = `${total} OK`;
106
+ }
107
+ const finalCurrent = Math.max(total, progressCursor + 1);
108
+ notifyProgress(extra, {
109
+ current: finalCurrent,
110
+ total: finalCurrent,
111
+ message: `🕮 stat_many: ${context} • ${suffix}`,
112
+ });
113
+ return result;
114
+ }
115
+ catch (error) {
116
+ const finalCurrent = Math.max(progressCursor + 1, 1);
117
+ notifyProgress(extra, {
118
+ current: finalCurrent,
119
+ total: finalCurrent,
120
+ message: `🕮 stat_many: ${context} • failed`,
121
+ });
122
+ throw error;
123
+ }
124
+ },
75
125
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FOUND, primaryPath),
76
126
  });
77
127
  };
78
128
  const wrappedHandler = wrapToolHandler(handler, {
79
129
  guard: options.isInitialized,
80
- progressMessage: (args) => {
81
- const first = path.basename(args.paths[0] ?? '');
82
- const extra = args.paths.length > 1 ? `, ${path.basename(args.paths[1] ?? '')}…` : '';
83
- return `🕮 stat_many: ${args.paths.length} paths [${first}${extra}]`;
84
- },
85
- completionMessage: (args, result) => {
86
- if (result.isError)
87
- return `🕮 stat_many: ${args.paths.length} paths • failed`;
88
- const sc = result.structuredContent;
89
- if (!sc.ok)
90
- return `🕮 stat_many: ${args.paths.length} paths • failed`;
91
- const total = sc.summary?.total ?? 0;
92
- const succeeded = sc.summary?.succeeded ?? 0;
93
- const failed = sc.summary?.failed ?? 0;
94
- if (failed)
95
- return `🕮 stat_many: ${succeeded}/${total} OK, ${failed} failed`;
96
- return `🕮 stat_many: ${total} OK`;
97
- },
98
130
  });
99
131
  const validatedHandler = withValidatedArgs(GetMultipleFileInfoInputSchema, wrappedHandler);
100
132
  if (registerToolTaskIfAvailable(server, 'stat_many', GET_MULTIPLE_FILE_INFO_TOOL, validatedHandler, options.iconInfo, options.isInitialized))
@@ -3,7 +3,7 @@ import { DEFAULT_SEARCH_TIMEOUT_MS } from '../lib/constants.js';
3
3
  import { ErrorCode } from '../lib/errors.js';
4
4
  import { formatTreeAscii, treeDirectory } from '../lib/file-operations/tree.js';
5
5
  import { TreeInputSchema, TreeOutputSchema } from '../schemas.js';
6
- import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
6
+ import { buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
7
  import { registerToolTaskIfAvailable } from './task-support.js';
8
8
  export const TREE_TOOL = {
9
9
  name: 'tree',
@@ -16,7 +16,7 @@ export const TREE_TOOL = {
16
16
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
17
17
  gotchas: ['`maxDepth=0` returns only the root node.'],
18
18
  };
19
- async function handleTree(args, signal) {
19
+ async function handleTree(args, signal, onProgress) {
20
20
  const basePath = resolvePathOrRoot(args.path);
21
21
  const result = await treeDirectory(basePath, {
22
22
  maxDepth: args.maxDepth,
@@ -24,6 +24,7 @@ async function handleTree(args, signal) {
24
24
  includeHidden: args.includeHidden,
25
25
  includeIgnored: args.includeIgnored,
26
26
  ...(signal ? { signal } : {}),
27
+ ...(onProgress ? { onProgress } : {}),
27
28
  });
28
29
  const ascii = formatTreeAscii(result.tree);
29
30
  const structured = {
@@ -45,30 +46,54 @@ export function registerTreeTool(server, options = {}) {
45
46
  extra,
46
47
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
47
48
  context: { path: targetPath },
48
- run: (signal) => handleTree(args, signal),
49
+ run: async (signal) => {
50
+ const context = args.path ? path.basename(args.path) : '.';
51
+ let progressCursor = 0;
52
+ notifyProgress(extra, {
53
+ current: 0,
54
+ message: `≣ tree: ${context}`,
55
+ });
56
+ const baseReporter = createProgressReporter(extra);
57
+ const onProgress = (progress) => {
58
+ const { current } = progress;
59
+ if (current > progressCursor)
60
+ progressCursor = current;
61
+ baseReporter({
62
+ current,
63
+ message: `≣ tree: ${context} [${current} entries]`,
64
+ });
65
+ };
66
+ try {
67
+ const result = await handleTree(args, signal, onProgress);
68
+ const sc = result.structuredContent;
69
+ const count = sc.totalEntries ?? 0;
70
+ const { truncated } = sc;
71
+ let suffix = `${count} ${count === 1 ? 'entry' : 'entries'}`;
72
+ if (truncated)
73
+ suffix += ' [truncated]';
74
+ const finalCurrent = Math.max(count, progressCursor + 1);
75
+ notifyProgress(extra, {
76
+ current: finalCurrent,
77
+ total: finalCurrent,
78
+ message: `≣ tree: ${context} • ${suffix}`,
79
+ });
80
+ return result;
81
+ }
82
+ catch (error) {
83
+ const finalCurrent = Math.max(progressCursor + 1, 1);
84
+ notifyProgress(extra, {
85
+ current: finalCurrent,
86
+ total: finalCurrent,
87
+ message: `≣ tree: ${context} • failed`,
88
+ });
89
+ throw error;
90
+ }
91
+ },
49
92
  onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_DIRECTORY, targetPath),
50
93
  });
51
94
  };
52
95
  const wrappedHandler = wrapToolHandler(handler, {
53
96
  guard: options.isInitialized,
54
- progressMessage: (args) => {
55
- if (args.path) {
56
- return `≣ tree: ${path.basename(args.path)}`;
57
- }
58
- return '≣ tree';
59
- },
60
- completionMessage: (args, result) => {
61
- const base = args.path ? path.basename(args.path) : '.';
62
- if (result.isError)
63
- return `≣ tree: ${base} • failed`;
64
- const sc = result.structuredContent;
65
- if (!sc.ok)
66
- return `≣ tree: ${base} • failed`;
67
- const count = sc.totalEntries ?? 0;
68
- if (sc.truncated)
69
- return `≣ tree: ${base} • ${count} entries [truncated]`;
70
- return `≣ tree: ${base} • ${count} ${count === 1 ? 'entry' : 'entries'}`;
71
- },
72
97
  });
73
98
  const validatedHandler = withValidatedArgs(TreeInputSchema, wrappedHandler);
74
99
  if (registerToolTaskIfAvailable(server, 'tree', TREE_TOOL, validatedHandler, options.iconInfo, options.isInitialized))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/filesystem-mcp",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "mcpName": "io.github.j0hanz/filesystem-mcp",
5
5
  "description": "MCP Server that enables LLMs to interact with the local filesystem.",
6
6
  "type": "module",