@j0hanz/filesystem-mcp 1.7.1 → 1.7.3

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.
@@ -9,21 +9,21 @@ diff_files (patch text) -> apply_patch.patch
9
9
 
10
10
  ## Search Strategy
11
11
 
12
- - Use \`find\` for glob-based file discovery.
13
- - Use \`grep\` for content-based searches.
14
- - Use \`search_and_replace\` ONLY for bulk replacements, not for discovery.
12
+ - Use \`find\` for glob file discovery.
13
+ - Use \`grep\` for text search.
14
+ - Use \`search_and_replace\` only for replacement, never discovery.
15
15
 
16
16
  ## Write Strategy
17
17
 
18
- - Use \`edit\` for precise, single-occurrence string replacements in existing files.
19
- - Use \`write\` to create new files or completely overwrite existing content.
20
- - Use \`search_and_replace\` for bulk regex replacements across multiple files.
18
+ - Use \`edit\` for precise, first-occurrence replacements in existing files.
19
+ - Use \`write\` to create files or overwrite full contents.
20
+ - Use \`search_and_replace\` for bulk multi-file replacements.
21
21
 
22
22
  ## Patch Management
23
23
 
24
- - Always generate a patch with \`diff_files\` first.
25
- - Always use \`dryRun: true\` with \`apply_patch\` to verify changes.
26
- - \`apply_patch\` works on unified diff format.
24
+ - Generate patches with \`diff_files\` first.
25
+ - Run \`apply_patch\` with \`dryRun: true\` before writing.
26
+ - \`apply_patch\` accepts unified diffs only.
27
27
  </tool_selection_guide>
28
28
  `;
29
29
  export function buildToolCatalog() {
@@ -37,10 +37,10 @@ export function buildCoreContextPack() {
37
37
  }
38
38
  export function getSharedConstraints() {
39
39
  return [
40
- 'Allowed roots only (negotiated via CLI).',
41
- 'Sensitive files denylisted by default.',
42
- `Max file size (${Math.floor(MAX_TEXT_FILE_SIZE / 1024 / 1024)}MB) & search results (${MAX_SEARCH_RESULTS} files, ${DEFAULT_SEARCH_CONTENT_RESULTS} lines) enforced.`,
43
- 'If a response includes `resourceUri`, call `resources/read` immediately results expire on process restart.',
40
+ 'Use allowed roots only (provided by CLI negotiation).',
41
+ 'Sensitive paths are denylisted by default.',
42
+ `Limits are enforced: max file size ${Math.floor(MAX_TEXT_FILE_SIZE / 1024 / 1024)}MB; search caps ${MAX_SEARCH_RESULTS} files and ${DEFAULT_SEARCH_CONTENT_RESULTS} lines.`,
43
+ 'If a response includes `resourceUri`, call `resources/read` immediately; cached results expire on process restart.',
44
44
  ];
45
45
  }
46
46
  export function buildToolInfo(name) {
@@ -49,7 +49,7 @@ export function buildToolInfo(name) {
49
49
  return undefined;
50
50
  const lines = [`## ${entry.name}`, '', entry.description];
51
51
  if (entry.annotations && entry.annotations.length > 0) {
52
- lines.push('', `**Annotations:** ${entry.annotations.join(', ')}`);
52
+ lines.push('', `**Hints:** ${entry.annotations.join(', ')}`);
53
53
  }
54
54
  if (entry.nuances && entry.nuances.length > 0) {
55
55
  lines.push('', '**Nuances:**');
@@ -1,33 +1,33 @@
1
1
  export function buildWorkflowGuide() {
2
2
  return `<workflows>
3
3
  ### A: EXPLORE
4
- Use when: navigating an unfamiliar directory or reading file content.
5
- 1. \`roots\` (List allowed paths).
6
- 2. \`ls\` (files) | \`tree\` (structure).
7
- 3. \`stat\` | \`stat_many\` (size/type check).
8
- 4. \`read\` | \`read_many\` (content).
9
- > **Strict:** Never guess paths. Resolve first.
4
+ Use when: you need directory layout or file content.
5
+ 1. \`roots\` (list allowed paths).
6
+ 2. \`ls\` (flat view) or \`tree\` (recursive view).
7
+ 3. \`stat\` or \`stat_many\` (type and size checks).
8
+ 4. \`read\` or \`read_many\` (read content).
9
+ > **Strict:** Resolve paths first. Never guess.
10
10
 
11
11
  ### B: SEARCH
12
- Use when: locating files by name pattern or by content match.
12
+ Use when: you need files by pattern or content.
13
13
  1. \`find\` (glob candidates).
14
- 2. \`grep\` (content search).
15
- 3. \`read\` (verify context).
16
- > **Strict:** Use \`grep\` for content search, not \`find\`.
14
+ 2. \`grep\` (content matches).
15
+ 3. \`read\` (verify matched context).
16
+ > **Strict:** Do content search with \`grep\`, not \`find\`.
17
17
 
18
18
  ### C: EDIT
19
- Use when: modifying existing files or reorganizing the filesystem.
20
- 1. \`edit\` (precise string match).
21
- 2. \`search_and_replace\` (bulk regex/glob).
22
- 3. \`mv\` | \`rm\` (file layout).
23
- 4. \`mkdir\` (create dirs).
19
+ Use when: you need to modify files or layout.
20
+ 1. \`edit\` (targeted string replacement).
21
+ 2. \`search_and_replace\` (bulk replacements).
22
+ 3. \`mv\` or \`rm\` (layout changes).
23
+ 4. \`mkdir\` (directory creation).
24
24
  > **Strict:** Confirm destructive ops (\`write\`, \`mv\`, \`rm\`, bulk replace).
25
25
 
26
26
  ### D: PATCH
27
- Use when: applying structured diffs produced by \`diff_files\`.
27
+ Use when: applying unified diffs from \`diff_files\`.
28
28
  1. \`diff_files\` (generate).
29
29
  2. \`apply_patch\` (dryRun: true).
30
30
  3. \`apply_patch\` (dryRun: false).
31
- > **Tip:** Pass \`diff_files\` output directly into \`apply_patch\`.
31
+ > **Tip:** Feed \`diff_files\` output directly to \`apply_patch\`.
32
32
  </workflows>`;
33
33
  }
package/dist/schemas.js CHANGED
@@ -18,6 +18,9 @@ function isSafeGlobPattern(value) {
18
18
  const MAX_PATH_LENGTH = 4096;
19
19
  const DESC_PATH_ROOT = 'Base directory (default: root). Absolute path required if multiple roots exist. Examples: "src", "src/components"';
20
20
  const DESC_PATH_REQUIRED = 'Absolute path to file or directory. Examples: "src/index.ts", "README.md"';
21
+ function defaultFalseBoolean(description) {
22
+ return z.boolean().optional().default(false).describe(description);
23
+ }
21
24
  const PathSchemaBase = z
22
25
  .string()
23
26
  .max(MAX_PATH_LENGTH, `Path too long (max ${MAX_PATH_LENGTH} chars)`);
@@ -109,16 +112,8 @@ const OperationSummarySchema = z.strictObject({
109
112
  });
110
113
  export const ListDirectoryInputSchema = z.strictObject({
111
114
  path: OptionalPathSchema.describe(DESC_PATH_ROOT),
112
- includeHidden: z
113
- .boolean()
114
- .optional()
115
- .default(false)
116
- .describe('Include hidden items (starting with .)'),
117
- includeIgnored: z
118
- .boolean()
119
- .optional()
120
- .default(false)
121
- .describe('Include ignored items (node_modules, .git, etc).'),
115
+ includeHidden: defaultFalseBoolean('Include hidden items (starting with .)'),
116
+ includeIgnored: defaultFalseBoolean('Include ignored items (node_modules, .git, etc).'),
122
117
  maxDepth: z
123
118
  .number()
124
119
  .int({ error: 'Must be integer' })
@@ -143,11 +138,7 @@ export const ListDirectoryInputSchema = z.strictObject({
143
138
  .max(1000, 'Max 1000 chars')
144
139
  .optional()
145
140
  .describe('Optional glob pattern filter (e.g. "**/*.ts")'),
146
- includeSymlinkTargets: z
147
- .boolean()
148
- .optional()
149
- .default(false)
150
- .describe('Resolve and include symlink targets in results'),
141
+ includeSymlinkTargets: defaultFalseBoolean('Resolve and include symlink targets in results'),
151
142
  cursor: z
152
143
  .string()
153
144
  .optional()
@@ -174,16 +165,8 @@ export const SearchFilesInputSchema = z.strictObject({
174
165
  .optional()
175
166
  .default(DEFAULT_SEARCH_RESULTS)
176
167
  .describe(`Max results (1-${MAX_SEARCH_RESULTS}). Default: ${DEFAULT_SEARCH_RESULTS}`),
177
- includeIgnored: z
178
- .boolean()
179
- .optional()
180
- .default(false)
181
- .describe('Include ignored items (node_modules, etc).'),
182
- includeHidden: z
183
- .boolean()
184
- .optional()
185
- .default(false)
186
- .describe('Include hidden items (starting with .)'),
168
+ includeIgnored: defaultFalseBoolean('Include ignored items (node_modules, etc).'),
169
+ includeHidden: defaultFalseBoolean('Include hidden items (starting with .)'),
187
170
  sortBy: SearchFilesSortSchema.optional()
188
171
  .default('path')
189
172
  .describe('Sort by path, name, size, or modified'),
@@ -217,16 +200,8 @@ export const TreeInputSchema = z.strictObject({
217
200
  .optional()
218
201
  .default(DEFAULT_TREE_ENTRIES)
219
202
  .describe(`Max entries. Default: ${DEFAULT_TREE_ENTRIES}`),
220
- includeHidden: z
221
- .boolean()
222
- .optional()
223
- .default(false)
224
- .describe('Include hidden items (starting with .)'),
225
- includeIgnored: z
226
- .boolean()
227
- .optional()
228
- .default(false)
229
- .describe('Include ignored items. Disables .gitignore.'),
203
+ includeHidden: defaultFalseBoolean('Include hidden items (starting with .)'),
204
+ includeIgnored: defaultFalseBoolean('Include ignored items. Disables .gitignore.'),
230
205
  });
231
206
  export const SearchContentInputSchema = z.strictObject({
232
207
  path: OptionalPathSchema.describe(DESC_PATH_ROOT),
@@ -235,21 +210,9 @@ export const SearchContentInputSchema = z.strictObject({
235
210
  .min(1, 'Pattern required')
236
211
  .max(1000, 'Max 1000 chars')
237
212
  .describe('Literal text to search for by default; treated as RE2 regex when isRegex is true.'),
238
- isRegex: z
239
- .boolean()
240
- .optional()
241
- .default(false)
242
- .describe('Treat pattern as a RE2 regular expression. RE2 does not support lookahead, lookbehind, or backreferences.'),
243
- caseSensitive: z
244
- .boolean()
245
- .optional()
246
- .default(false)
247
- .describe('Case-sensitive matching (default: false — searches are case-insensitive).'),
248
- wholeWord: z
249
- .boolean()
250
- .optional()
251
- .default(false)
252
- .describe('Match whole words only'),
213
+ isRegex: defaultFalseBoolean('Treat pattern as a RE2 regular expression. RE2 does not support lookahead, lookbehind, or backreferences.'),
214
+ caseSensitive: defaultFalseBoolean('Case-sensitive matching (default: false — searches are case-insensitive).'),
215
+ wholeWord: defaultFalseBoolean('Match whole words only'),
253
216
  contextLines: z
254
217
  .number()
255
218
  .int({ error: 'Must be integer' })
@@ -273,16 +236,8 @@ export const SearchContentInputSchema = z.strictObject({
273
236
  .optional()
274
237
  .default('**/*')
275
238
  .describe('Glob for candidate files (e.g. "**/*.ts")'),
276
- includeHidden: z
277
- .boolean()
278
- .optional()
279
- .default(false)
280
- .describe('Include hidden items (starting with .)'),
281
- includeIgnored: z
282
- .boolean()
283
- .optional()
284
- .default(false)
285
- .describe('Include ignored items (node_modules, etc).'),
239
+ includeHidden: defaultFalseBoolean('Include hidden items (starting with .)'),
240
+ includeIgnored: defaultFalseBoolean('Include ignored items (node_modules, etc).'),
286
241
  });
287
242
  export const ReadFileInputSchema = z
288
243
  .strictObject({
@@ -511,16 +466,8 @@ export const EditFileInputSchema = z.strictObject({
511
466
  }))
512
467
  .min(1, 'Min 1 edit required')
513
468
  .describe('List of replacements to apply sequentially. Each edit replaces the first occurrence of oldText.'),
514
- dryRun: z
515
- .boolean()
516
- .optional()
517
- .default(false)
518
- .describe('Preview edits without writing. Check unmatchedEdits in the response to verify all oldText values were found.'),
519
- ignoreWhitespace: z
520
- .boolean()
521
- .optional()
522
- .default(false)
523
- .describe('Ignore leading/trailing whitespace and treat all whitespace sequences as equivalent when matching oldText.'),
469
+ dryRun: defaultFalseBoolean('Preview edits without writing. Check unmatchedEdits in the response to verify all oldText values were found.'),
470
+ ignoreWhitespace: defaultFalseBoolean('Ignore leading/trailing whitespace and treat all whitespace sequences as equivalent when matching oldText.'),
524
471
  });
525
472
  export const EditFileOutputSchema = z.strictObject({
526
473
  ok: z.boolean(),
@@ -562,16 +509,8 @@ export const MoveFileOutputSchema = z.strictObject({
562
509
  });
563
510
  export const DeleteFileInputSchema = z.strictObject({
564
511
  path: RequiredPathSchema.describe(DESC_PATH_REQUIRED),
565
- recursive: z
566
- .boolean()
567
- .optional()
568
- .default(false)
569
- .describe('Delete non-empty directories'),
570
- ignoreIfNotExists: z
571
- .boolean()
572
- .optional()
573
- .default(false)
574
- .describe('No error if missing'),
512
+ recursive: defaultFalseBoolean('Delete non-empty directories'),
513
+ ignoreIfNotExists: defaultFalseBoolean('No error if missing'),
575
514
  });
576
515
  export const DeleteFileOutputSchema = z.strictObject({
577
516
  ok: z.boolean(),
@@ -665,16 +604,8 @@ export const SearchAndReplaceInputSchema = z.strictObject({
665
604
  .min(1, 'Search pattern required')
666
605
  .describe('Text to search for. Matched literally by default; treated as RE2 regex when isRegex is true.'),
667
606
  replacement: z.string().describe('Replacement text'),
668
- isRegex: z
669
- .boolean()
670
- .optional()
671
- .default(false)
672
- .describe('Treat searchPattern as a RE2 regular expression. Supports capture group references ($1, $2) in replacement.'),
673
- dryRun: z
674
- .boolean()
675
- .optional()
676
- .default(false)
677
- .describe('Preview matches without writing. Check changedFiles and matches in the response before committing.'),
607
+ isRegex: defaultFalseBoolean('Treat searchPattern as a RE2 regular expression. Supports capture group references ($1, $2) in replacement.'),
608
+ dryRun: defaultFalseBoolean('Preview matches without writing. Check changedFiles and matches in the response before committing.'),
678
609
  includeHidden: z
679
610
  .boolean()
680
611
  .optional()
@@ -7,7 +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, REQUIRED_MCP_PROTOCOL_VERSION, } from '../lib/constants.js';
10
+ import { DEFAULT_LOG_LEVEL, parseEnvInt, REQUIRED_MCP_PROTOCOL_VERSION, } from '../lib/constants.js';
11
11
  import { formatUnknownErrorMessage } from '../lib/errors.js';
12
12
  import { createInMemoryResourceStore } from '../lib/resource-store.js';
13
13
  import { pkgInfo } from '../pkg-info.js';
@@ -66,8 +66,8 @@ export async function createServer(options = {}) {
66
66
  if (serverInstructions) {
67
67
  serverConfig.instructions =
68
68
  'filesystem-mcp: Secure local filesystem MCP server. ' +
69
- 'Always begin with: roots ls/find stat read. Never guess paths. ' +
70
- 'Full reference: read the internal://instructions resource or invoke the get-help prompt.';
69
+ 'Start with: roots -> ls/find -> stat -> read. Never guess paths. ' +
70
+ 'For full guidance, read internal://instructions or run the get-help prompt.';
71
71
  }
72
72
  const server = new McpServer(withDefaultIcons({
73
73
  name: 'filesystem-mcp',
@@ -104,16 +104,14 @@ export async function startServer(server) {
104
104
  rootsManager.registerHandlers(server);
105
105
  await rootsManager.recomputeAllowedDirectories();
106
106
  await server.connect(transport);
107
- const transportAny = transport;
108
- const sdkOnClose = transportAny.onclose;
109
- transportAny.onclose = () => {
107
+ const sdkOnClose = transport.onclose;
108
+ transport.onclose = () => {
110
109
  rootsManager.destroy();
111
110
  sdkOnClose?.();
112
111
  };
113
112
  rootsManager.logMissingDirectoriesIfNeeded(server);
114
113
  }
115
- const MAX_REQUEST_BODY_BYTES = parseInt(process.env['FS_CONTEXT_MAX_REQUEST_BYTES'] ?? '', 10) ||
116
- 4 * 1024 * 1024; // 4 MB default
114
+ const MAX_REQUEST_BODY_BYTES = parseEnvInt('FS_CONTEXT_MAX_REQUEST_BYTES', 4 * 1024 * 1024, 1024, 256 * 1024 * 1024);
117
115
  class RequestBodyError extends Error {
118
116
  statusCode;
119
117
  constructor(message, statusCode) {
@@ -4,7 +4,7 @@ import { DEFAULT_EXCLUDE_PATTERNS } from '../lib/constants.js';
4
4
  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
- import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
+ import { buildToolErrorResponse, buildToolResponse, decodeOffsetCursor, encodeOffsetCursor, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
8
  import { registerToolTaskIfAvailable } from './task-support.js';
9
9
  export const LIST_DIRECTORY_TOOL = {
10
10
  name: 'ls',
@@ -82,27 +82,9 @@ function buildStructuredListResult(result, nextCursor) {
82
82
  ...(nextCursor !== undefined ? { nextCursor } : {}),
83
83
  };
84
84
  }
85
- function encodeCursor(offset) {
86
- return Buffer.from(JSON.stringify({ offset })).toString('base64url');
87
- }
88
- function decodeCursor(cursor) {
89
- try {
90
- const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'));
91
- if (typeof parsed === 'object' &&
92
- parsed !== null &&
93
- typeof parsed.offset === 'number') {
94
- const { offset } = parsed;
95
- return Number.isInteger(offset) && offset >= 0 ? offset : 0;
96
- }
97
- }
98
- catch {
99
- // ignore malformed cursor
100
- }
101
- return 0;
102
- }
103
85
  async function handleListDirectory(args, signal) {
104
86
  const dirPath = resolvePathOrRoot(args.path);
105
- const cursorOffset = args.cursor !== undefined ? decodeCursor(args.cursor) : 0;
87
+ const cursorOffset = args.cursor !== undefined ? decodeOffsetCursor(args.cursor) : 0;
106
88
  const pageSize = args.maxEntries;
107
89
  const options = {
108
90
  includeHidden: args.includeHidden,
@@ -117,7 +99,7 @@ async function handleListDirectory(args, signal) {
117
99
  const result = await listDirectory(dirPath, options);
118
100
  const displayEntries = cursorOffset > 0 ? result.entries.slice(cursorOffset) : result.entries;
119
101
  const nextCursor = result.summary.truncated && displayEntries.length > 0
120
- ? encodeCursor(cursorOffset + displayEntries.length)
102
+ ? encodeOffsetCursor(cursorOffset + displayEntries.length)
121
103
  : undefined;
122
104
  const displayResult = { ...result, entries: displayEntries };
123
105
  return buildToolResponse(buildListTextResult(displayResult, nextCursor), buildStructuredListResult(displayResult, nextCursor));
@@ -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, createToolProgressSession, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
6
+ import { buildBatchPathContext, buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, maybeExternalizeTextContent, 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',
@@ -20,6 +20,23 @@ export const READ_MULTIPLE_FILES_TOOL = {
20
20
  'Per-file `truncationReason` can be `head`, `range`, or `externalized`.',
21
21
  ],
22
22
  };
23
+ function buildReadManyCompletionSuffix(summary) {
24
+ const total = summary?.total ?? 0;
25
+ const failed = summary?.failed ?? 0;
26
+ const succeeded = summary?.succeeded ?? 0;
27
+ if (failed) {
28
+ return `${succeeded}/${total} read, ${failed} failed`;
29
+ }
30
+ const label = total === 1 ? 'file' : 'files';
31
+ return `${total} ${label} read`;
32
+ }
33
+ function createReadManyProgressCallbacks(extra, context, totalPaths) {
34
+ const progress = createToolProgressSession(extra, `🕮 read_many: ${context}`);
35
+ const onReadComplete = () => {
36
+ progress.increment((current) => `🕮 read_many: ${context} [${current}/${totalPaths} read]`);
37
+ };
38
+ return { progress, onReadComplete };
39
+ }
23
40
  function toStructuredReadManyResult(result) {
24
41
  const structured = {
25
42
  path: result.path,
@@ -134,28 +151,13 @@ export function registerReadMultipleFilesTool(server, options = {}) {
134
151
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
135
152
  context: { path: primaryPath },
136
153
  run: async (signal) => {
137
- const first = path.basename(args.paths[0] ?? '');
138
- const extraPaths = args.paths.length > 1
139
- ? `, ${path.basename(args.paths[1] ?? '')}${args.paths.length > 2 ? '…' : ''}`
140
- : '';
141
- const context = `${args.paths.length} files [${first}${extraPaths}]`;
142
- const progress = createToolProgressSession(extra, `🕮 read_many: ${context}`);
143
- const onReadComplete = () => {
144
- progress.increment((current) => `🕮 read_many: ${context} [${current}/${args.paths.length} read]`);
145
- };
154
+ const context = buildBatchPathContext(args.paths, 'files');
155
+ const { progress, onReadComplete } = createReadManyProgressCallbacks(extra, context, args.paths.length);
146
156
  try {
147
157
  const result = await handleReadMultipleFiles(args, signal, options.resourceStore, onReadComplete);
148
158
  const sc = result.structuredContent;
159
+ const suffix = buildReadManyCompletionSuffix(sc.summary);
149
160
  const total = sc.summary?.total ?? 0;
150
- const failed = sc.summary?.failed ?? 0;
151
- const succeeded = sc.summary?.succeeded ?? 0;
152
- let suffix;
153
- if (failed) {
154
- suffix = `${succeeded}/${total} read, ${failed} failed`;
155
- }
156
- else {
157
- suffix = `${total} files read`;
158
- }
159
161
  const finalCurrent = Math.max(total, progress.getCurrent() + 1);
160
162
  progress.complete(`🕮 read_many: ${context} • ${suffix}`, finalCurrent);
161
163
  return result;
@@ -4,26 +4,8 @@ import { DEFAULT_EXCLUDE_PATTERNS, DEFAULT_SEARCH_TIMEOUT_MS, } from '../lib/con
4
4
  import { ErrorCode } from '../lib/errors.js';
5
5
  import { searchFiles } from '../lib/file-operations/search-files.js';
6
6
  import { SearchFilesInputSchema, SearchFilesOutputSchema } from '../schemas.js';
7
- import { buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
+ import { buildToolErrorResponse, buildToolResponse, createProgressReporter, decodeOffsetCursor, encodeOffsetCursor, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
8
  import { registerToolTaskIfAvailable } from './task-support.js';
9
- function encodeCursor(offset) {
10
- return Buffer.from(JSON.stringify({ offset })).toString('base64url');
11
- }
12
- function decodeCursor(cursor) {
13
- try {
14
- const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'));
15
- if (typeof parsed === 'object' &&
16
- parsed !== null &&
17
- typeof parsed.offset === 'number') {
18
- const { offset } = parsed;
19
- return Number.isInteger(offset) && offset >= 0 ? offset : 0;
20
- }
21
- }
22
- catch {
23
- // ignore malformed cursor
24
- }
25
- return 0;
26
- }
27
9
  export const SEARCH_FILES_TOOL = {
28
10
  name: 'find',
29
11
  title: 'Find Files',
@@ -43,7 +25,7 @@ export const SEARCH_FILES_TOOL = {
43
25
  async function handleSearchFiles(args, signal, onProgress) {
44
26
  const basePath = resolvePathOrRoot(args.path);
45
27
  const excludePatterns = args.includeIgnored ? [] : DEFAULT_EXCLUDE_PATTERNS;
46
- const cursorOffset = args.cursor !== undefined ? decodeCursor(args.cursor) : 0;
28
+ const cursorOffset = args.cursor !== undefined ? decodeOffsetCursor(args.cursor) : 0;
47
29
  const pageSize = args.maxResults;
48
30
  const fetchMax = cursorOffset + pageSize;
49
31
  const searchOptions = {
@@ -59,7 +41,7 @@ async function handleSearchFiles(args, signal, onProgress) {
59
41
  const allResults = result.results;
60
42
  const displayResults = cursorOffset > 0 ? allResults.slice(cursorOffset) : allResults;
61
43
  const nextCursor = result.summary.truncated && displayResults.length > 0
62
- ? encodeCursor(cursorOffset + displayResults.length)
44
+ ? encodeOffsetCursor(cursorOffset + displayResults.length)
63
45
  : undefined;
64
46
  const relativeResults = [];
65
47
  for (const entry of displayResults) {
@@ -139,3 +139,6 @@ export declare function wrapToolHandler<Args, Result>(handler: (args: Args, extr
139
139
  * See `src/server/roots-manager.ts` for the update lifecycle.
140
140
  */
141
141
  export declare function resolvePathOrRoot(pathValue: string | undefined): string;
142
+ export declare function encodeOffsetCursor(offset: number): string;
143
+ export declare function decodeOffsetCursor(cursor: string): number;
144
+ export declare function buildBatchPathContext(paths: readonly string[], unitLabel?: string): string;
@@ -1,3 +1,4 @@
1
+ import * as path from 'node:path';
1
2
  import { channel } from 'node:diagnostics_channel';
2
3
  import { z } from 'zod';
3
4
  import { parseTrueEnvFlag } from '../lib/constants.js';
@@ -403,3 +404,29 @@ export function resolvePathOrRoot(pathValue) {
403
404
  }
404
405
  return root;
405
406
  }
407
+ export function encodeOffsetCursor(offset) {
408
+ return Buffer.from(JSON.stringify({ offset })).toString('base64url');
409
+ }
410
+ export function decodeOffsetCursor(cursor) {
411
+ try {
412
+ const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'));
413
+ if (typeof parsed === 'object' &&
414
+ parsed !== null &&
415
+ typeof parsed.offset === 'number') {
416
+ const { offset } = parsed;
417
+ return Number.isInteger(offset) && offset >= 0 ? offset : 0;
418
+ }
419
+ }
420
+ catch {
421
+ // ignore malformed cursor
422
+ }
423
+ return 0;
424
+ }
425
+ export function buildBatchPathContext(paths, unitLabel = 'paths') {
426
+ const normalizedLabel = paths.length === 1 ? unitLabel.replace(/s$/i, '') : unitLabel;
427
+ const first = path.basename(paths[0] ?? '');
428
+ const extraPaths = paths.length > 1
429
+ ? `, ${path.basename(paths[1] ?? '')}${paths.length > 2 ? '…' : ''}`
430
+ : '';
431
+ return `${paths.length} ${normalizedLabel} [${first}${extraPaths}]`;
432
+ }
@@ -1,10 +1,9 @@
1
- import * as path from 'node:path';
2
1
  import { formatBytes, joinLines } from '../config.js';
3
2
  import { DEFAULT_SEARCH_TIMEOUT_MS } from '../lib/constants.js';
4
3
  import { ErrorCode } from '../lib/errors.js';
5
4
  import { getMultipleFileInfo } from '../lib/file-operations/file-info.js';
6
5
  import { GetMultipleFileInfoInputSchema, GetMultipleFileInfoOutputSchema, } from '../schemas.js';
7
- import { buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
6
+ import { buildBatchPathContext, buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
7
  import { registerToolTaskIfAvailable } from './task-support.js';
9
8
  export const GET_MULTIPLE_FILE_INFO_TOOL = {
10
9
  name: 'stat_many',
@@ -16,6 +15,22 @@ export const GET_MULTIPLE_FILE_INFO_TOOL = {
16
15
  taskSupport: 'optional',
17
16
  nuances: ['Use before read/search when file size/type uncertainty exists.'],
18
17
  };
18
+ function buildStatManyCompletionSuffix(summary) {
19
+ const total = summary?.total ?? 0;
20
+ const failed = summary?.failed ?? 0;
21
+ const succeeded = summary?.succeeded ?? 0;
22
+ if (failed) {
23
+ return `${succeeded}/${total} OK, ${failed} failed`;
24
+ }
25
+ return `${total} OK`;
26
+ }
27
+ function createStatManyProgressCallbacks(extra, context, totalPaths) {
28
+ const progress = createToolProgressSession(extra, `🕮 stat_many: ${context}`);
29
+ const onProgress = () => {
30
+ progress.increment((current) => `🕮 stat_many: ${context} [${current}/${totalPaths} scanned]`);
31
+ };
32
+ return { progress, onProgress };
33
+ }
19
34
  function formatFileInfoDetail(info) {
20
35
  const lines = [
21
36
  `${info.name} (${info.type})`,
@@ -74,28 +89,13 @@ export function registerGetMultipleFileInfoTool(server, options = {}) {
74
89
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
75
90
  context: { path: primaryPath },
76
91
  run: async (signal) => {
77
- const first = path.basename(args.paths[0] ?? '');
78
- const extraPaths = args.paths.length > 1
79
- ? `, ${path.basename(args.paths[1] ?? '')}${args.paths.length > 2 ? '…' : ''}`
80
- : '';
81
- const context = `${args.paths.length} paths [${first}${extraPaths}]`;
82
- const progress = createToolProgressSession(extra, `🕮 stat_many: ${context}`);
83
- const onProgress = () => {
84
- progress.increment((current) => `🕮 stat_many: ${context} [${current}/${args.paths.length} scanned]`);
85
- };
92
+ const context = buildBatchPathContext(args.paths);
93
+ const { progress, onProgress } = createStatManyProgressCallbacks(extra, context, args.paths.length);
86
94
  try {
87
95
  const result = await handleGetMultipleFileInfo(args, signal, onProgress);
88
96
  const sc = result.structuredContent;
97
+ const suffix = buildStatManyCompletionSuffix(sc.summary);
89
98
  const total = sc.summary?.total ?? 0;
90
- const failed = sc.summary?.failed ?? 0;
91
- const succeeded = sc.summary?.succeeded ?? 0;
92
- let suffix;
93
- if (failed) {
94
- suffix = `${succeeded}/${total} OK, ${failed} failed`;
95
- }
96
- else {
97
- suffix = `${total} OK`;
98
- }
99
99
  const finalCurrent = Math.max(total, progress.getCurrent() + 1);
100
100
  progress.complete(`🕮 stat_many: ${context} • ${suffix}`, finalCurrent);
101
101
  return result;
@@ -20,14 +20,16 @@ function getExperimentalTaskRegistration(server) {
20
20
  return tasks;
21
21
  }
22
22
  function hasTaskToolCapability(server) {
23
- const maybeServer = server;
24
- const serverRuntime = maybeServer.server;
25
- const capabilityGetter = serverRuntime?.getCapabilities;
26
- if (typeof capabilityGetter !== 'function') {
23
+ const serverRecord = server;
24
+ const { server: serverRuntime } = serverRecord;
25
+ if (!isRecord(serverRuntime))
26
+ return true;
27
+ const { getCapabilities } = serverRuntime;
28
+ if (typeof getCapabilities !== 'function') {
27
29
  // Fallback for tests or custom wrappers that provide only registerTool/experimental.
28
30
  return true;
29
31
  }
30
- const capabilities = capabilityGetter.call(serverRuntime);
32
+ const capabilities = getCapabilities.call(serverRuntime);
31
33
  if (!isRecord(capabilities))
32
34
  return false;
33
35
  const { tasks } = capabilities;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/filesystem-mcp",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
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",