@j0hanz/filesystem-mcp 1.2.2 → 1.2.4

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 (48) hide show
  1. package/README.md +11 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +15 -5
  4. package/dist/completions.js +15 -6
  5. package/dist/index.js +26 -8
  6. package/dist/lib/file-operations/glob-engine.js +3 -9
  7. package/dist/lib/file-operations/read-multiple-files.js +4 -23
  8. package/dist/lib/file-operations/search-content.js +11 -18
  9. package/dist/lib/observability.js +4 -4
  10. package/dist/lib/path-validation.js +22 -9
  11. package/dist/pkg-info.d.ts +6 -0
  12. package/dist/pkg-info.js +9 -0
  13. package/dist/schemas.d.ts +28 -28
  14. package/dist/schemas.js +26 -26
  15. package/dist/server/bootstrap.d.ts +6 -0
  16. package/dist/server/bootstrap.js +300 -0
  17. package/dist/server/capabilities.d.ts +10 -0
  18. package/dist/server/capabilities.js +40 -0
  19. package/dist/server/logging.d.ts +7 -0
  20. package/dist/server/logging.js +41 -0
  21. package/dist/server/roots-manager.d.ts +19 -0
  22. package/dist/server/roots-manager.js +173 -0
  23. package/dist/server/types.d.ts +4 -0
  24. package/dist/server/types.js +1 -0
  25. package/dist/server.d.ts +2 -8
  26. package/dist/server.js +1 -317
  27. package/dist/tools/apply-patch.js +3 -2
  28. package/dist/tools/calculate-hash.js +3 -2
  29. package/dist/tools/create-directory.js +3 -2
  30. package/dist/tools/delete-file.js +9 -2
  31. package/dist/tools/diff-files.js +3 -2
  32. package/dist/tools/edit-file.js +5 -4
  33. package/dist/tools/list-directory.js +13 -2
  34. package/dist/tools/move-file.js +11 -3
  35. package/dist/tools/read-multiple.js +6 -5
  36. package/dist/tools/read.js +3 -2
  37. package/dist/tools/replace-in-files.js +3 -2
  38. package/dist/tools/roots.js +12 -2
  39. package/dist/tools/search-content.js +10 -15
  40. package/dist/tools/search-files.js +13 -9
  41. package/dist/tools/shared.d.ts +3 -1
  42. package/dist/tools/shared.js +19 -1
  43. package/dist/tools/stat-many.js +6 -5
  44. package/dist/tools/stat.js +4 -3
  45. package/dist/tools/task-support.js +57 -10
  46. package/dist/tools/tree.js +15 -2
  47. package/dist/tools/write-file.js +3 -2
  48. package/package.json +1 -1
package/README.md CHANGED
@@ -493,6 +493,13 @@ The server declares full task capabilities (`tasks/list`, `tasks/cancel`). The f
493
493
 
494
494
  Include `_meta.progressToken` in a `tools/call` request to receive `notifications/progress` updates. Use `tools/call` with a `task` field to invoke as a background task, then poll `tasks/get` and retrieve output via `tasks/result`.
495
495
 
496
+ Task status notifications (`notifications/tasks/status`) are best-effort and emitted only when the transport/runtime provides a notification sender.
497
+
498
+ Cancellation semantics:
499
+
500
+ - `tasks/cancel` is the canonical cancellation API.
501
+ - Clients should treat `E_CANCELLED` as cancellation even if a transport/client surfaces a terminal failure shape.
502
+
496
503
  ## Configuration
497
504
 
498
505
  ### CLI
@@ -535,6 +542,10 @@ Directories are resolved from three sources, merged at runtime:
535
542
  > [!TIP]
536
543
  > If no directories are configured at startup and the connected client does not supply MCP Roots, all tool calls will fail. Pass at least one directory argument or use `--allow-cwd`.
537
544
 
545
+ ### Compatibility
546
+
547
+ Set `FS_CONTEXT_STRIP_STRUCTURED=1` to strip `structuredContent` from tool results and `outputSchema` from tool definitions for compatibility with clients that only consume text content.
548
+
538
549
  ## Security
539
550
 
540
551
  - **Path validation**: All operations use `isPathWithinDirectories` to prevent path traversal attacks.
package/dist/cli.d.ts CHANGED
@@ -5,4 +5,5 @@ export declare class CliExitError extends Error {
5
5
  export declare function parseArgs(): Promise<{
6
6
  allowedDirs: string[];
7
7
  allowCwd: boolean;
8
+ port: number | undefined;
8
9
  }>;
package/dist/cli.js CHANGED
@@ -1,13 +1,11 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import { getSystemErrorMessage, getSystemErrorName } from 'node:util';
3
- import { z } from 'zod';
4
3
  import { Command, CommanderError, InvalidArgumentError } from 'commander';
5
- import packageJsonRaw from '../package.json' with { type: 'json' };
6
4
  import { processInParallel } from './lib/fs-helpers.js';
7
5
  import { getReservedDeviceNameForPath, isWindowsDriveRelativePath, normalizePath, } from './lib/path-validation.js';
8
6
  import { isRecord } from './lib/type-guards.js';
9
- const PackageJsonSchema = z.object({ version: z.string() });
10
- const { version: SERVER_VERSION } = PackageJsonSchema.parse(packageJsonRaw);
7
+ import { pkgInfo } from './pkg-info.js';
8
+ const { version: SERVER_VERSION } = pkgInfo;
11
9
  const IS_WINDOWS = process.platform === 'win32';
12
10
  const CLI_VALIDATE_CONCURRENCY = 8;
13
11
  export class CliExitError extends Error {
@@ -125,6 +123,7 @@ function createCliProgram(output) {
125
123
  .description('MCP filesystem server. Positional directories define allowed access roots.')
126
124
  .argument('[allowedDirs...]', 'Directories the MCP server can access on disk', parseAllowedDirArgument)
127
125
  .option('--allow_cwd, --allow-cwd', 'Allow the current working directory as an additional root')
126
+ .option('--port <number>', 'Enable HTTP transport on the given port (MCP Streamable HTTP with SSE)')
128
127
  .helpOption('-h, --help', 'Display command help')
129
128
  .version(SERVER_VERSION, '-v, --version', 'Display server version')
130
129
  .addHelpText('after', `
@@ -132,6 +131,7 @@ Examples:
132
131
  $ filesystem-mcp /path/to/allowed/dir
133
132
  $ filesystem-mcp --allow-cwd
134
133
  $ filesystem-mcp /project/src /project/tests --allow-cwd
134
+ $ filesystem-mcp --port 3000 /path/to/allowed/dir
135
135
  `);
136
136
  cli.allowUnknownOption(false);
137
137
  cli.allowExcessArguments(false);
@@ -173,6 +173,15 @@ function deduplicateAllowedDirectories(dirs) {
173
173
  }
174
174
  return deduplicated;
175
175
  }
176
+ function parsePortOption(raw) {
177
+ if (raw === undefined)
178
+ return undefined;
179
+ const n = Number(raw);
180
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
181
+ throw new CliExitError(`Error: --port must be an integer between 1 and 65535`, 1);
182
+ }
183
+ return n;
184
+ }
176
185
  export async function parseArgs() {
177
186
  const output = [];
178
187
  const cli = createCliProgram(output);
@@ -187,6 +196,7 @@ export async function parseArgs() {
187
196
  }
188
197
  const options = cli.opts();
189
198
  const allowCwd = options.allowCwd === true;
199
+ const port = parsePortOption(options.port);
190
200
  const positionals = getParsedAllowedDirs(cli);
191
201
  let allowedDirs;
192
202
  try {
@@ -197,5 +207,5 @@ export async function parseArgs() {
197
207
  throw new CliExitError(normalizeCliExitMessage(error), 1);
198
208
  }
199
209
  const deduplicatedDirs = deduplicateAllowedDirectories(allowedDirs);
200
- return { allowedDirs: deduplicatedDirs, allowCwd };
210
+ return { allowedDirs: deduplicatedDirs, allowCwd, port };
201
211
  }
@@ -195,7 +195,17 @@ async function toAllowedContextDirectory(resolved, allowed) {
195
195
  return isPathWithinDirectories(parent, allowed) ? parent : undefined;
196
196
  }
197
197
  async function resolveContextBaseDirectory(argumentName, contextArguments, allowed) {
198
- if (!contextArguments || Object.keys(contextArguments).length === 0) {
198
+ if (!contextArguments) {
199
+ return undefined;
200
+ }
201
+ let hasContextArgument = false;
202
+ for (const key in contextArguments) {
203
+ if (!Object.prototype.hasOwnProperty.call(contextArguments, key))
204
+ continue;
205
+ hasContextArgument = true;
206
+ break;
207
+ }
208
+ if (!hasContextArgument) {
199
209
  return undefined;
200
210
  }
201
211
  const keys = chooseContextKeys(argumentName);
@@ -277,10 +287,11 @@ function findRootPrefixMatches(currentValue, allowed) {
277
287
  function findMatchingRoots(searchDir, prefix, allowed) {
278
288
  const matches = [];
279
289
  const lowerPrefix = prefix.toLowerCase();
290
+ const normalizedSearchDir = normalizePath(searchDir);
280
291
  for (const root of allowed) {
281
292
  const rootDir = path.dirname(root);
282
293
  // Check if root is a direct child of searchDir
283
- if (normalizePath(rootDir) === searchDir) {
294
+ if (normalizePath(rootDir) === normalizedSearchDir) {
284
295
  const rootName = path.basename(root);
285
296
  if (rootName.toLowerCase().startsWith(lowerPrefix)) {
286
297
  matches.push(`${root}${path.sep}`);
@@ -312,10 +323,8 @@ export async function getPathCompletions(currentValue, options = {}) {
312
323
  };
313
324
  }
314
325
  const { searchDir, prefix } = context;
315
- const [dirMatches, rootMatches] = await Promise.all([
316
- findMatchesInDirectory(searchDir, prefix, allowed),
317
- Promise.resolve(findMatchingRoots(searchDir, prefix, allowed)),
318
- ]);
326
+ const dirMatches = await findMatchesInDirectory(searchDir, prefix, allowed);
327
+ const rootMatches = findMatchingRoots(searchDir, prefix, allowed);
319
328
  // Deduplicate and sort
320
329
  const unique = new Set();
321
330
  for (const match of dirMatches)
package/dist/index.js CHANGED
@@ -5,9 +5,10 @@ import { DEFAULT_SEARCH_TIMEOUT_MS } from './lib/constants.js';
5
5
  import { formatUnknownErrorMessage } from './lib/errors.js';
6
6
  import { createTimedAbortSignal } from './lib/fs-helpers.js';
7
7
  import { setAllowedDirectoriesResolved } from './lib/path-validation.js';
8
- import { createServer, startServer } from './server.js';
8
+ import { createServer, startHttpServer, startServer } from './server.js';
9
9
  const SHUTDOWN_TIMEOUT_MS = 5000;
10
10
  let activeServer;
11
+ let activeHttpServer;
11
12
  let shutdownStarted = false;
12
13
  function isStdinEvent(event) {
13
14
  return event === 'end' || event === 'close';
@@ -31,6 +32,14 @@ async function shutdown(reason, exitCode = 0) {
31
32
  }, SHUTDOWN_TIMEOUT_MS);
32
33
  timer.unref();
33
34
  try {
35
+ if (activeHttpServer) {
36
+ const server = activeHttpServer;
37
+ await new Promise((resolve) => {
38
+ server.close(() => {
39
+ resolve();
40
+ });
41
+ });
42
+ }
34
43
  if (activeServer) {
35
44
  await activeServer.close();
36
45
  }
@@ -48,9 +57,10 @@ async function shutdown(reason, exitCode = 0) {
48
57
  async function main() {
49
58
  let allowedDirs;
50
59
  let allowCwd;
60
+ let port;
51
61
  try {
52
62
  const parsed = await parseArgs();
53
- ({ allowedDirs, allowCwd } = parsed);
63
+ ({ allowedDirs, allowCwd, port } = parsed);
54
64
  }
55
65
  catch (error) {
56
66
  if (error instanceof CliExitError) {
@@ -78,12 +88,20 @@ async function main() {
78
88
  else {
79
89
  console.error(`No directories specified via CLI. Will use MCP Roots${allowCwd ? ' or current working directory' : ''}.`);
80
90
  }
81
- const server = await createServer({
82
- allowCwd,
83
- cliAllowedDirs: allowedDirs,
84
- });
85
- activeServer = server;
86
- await startServer(server);
91
+ if (port !== undefined) {
92
+ activeHttpServer = await startHttpServer(port, {
93
+ allowCwd,
94
+ cliAllowedDirs: allowedDirs,
95
+ });
96
+ }
97
+ else {
98
+ const server = await createServer({
99
+ allowCwd,
100
+ cliAllowedDirs: allowedDirs,
101
+ });
102
+ activeServer = server;
103
+ await startServer(server);
104
+ }
87
105
  }
88
106
  registerShutdownTrigger('SIGTERM');
89
107
  registerShutdownTrigger('SIGINT');
@@ -237,15 +237,9 @@ async function* processIterable(iterable, context) {
237
237
  const flush = async function* () {
238
238
  if (buffer.length === 0)
239
239
  return;
240
- const batchSize = buffer.length;
241
- const requests = new Array(batchSize);
242
- for (let index = 0; index < batchSize; index += 1) {
243
- const match = buffer[index];
244
- if (match === undefined) {
245
- requests[index] = Promise.resolve(null);
246
- continue;
247
- }
248
- requests[index] = resolveStringMatch(match, cwd, maxDepth, seen, onlyFiles, followSymlinks, returnStats, suppressErrors);
240
+ const requests = [];
241
+ for (const match of buffer) {
242
+ requests.push(resolveStringMatch(match, cwd, maxDepth, seen, onlyFiles, followSymlinks, returnStats, suppressErrors));
249
243
  }
250
244
  buffer.length = 0;
251
245
  const results = await Promise.all(requests);
@@ -109,16 +109,11 @@ async function validateBatch(tasks, signal) {
109
109
  }
110
110
  return infos;
111
111
  }
112
- async function applyBudgetForRange(options) {
113
- const { batchStart, batchEnd, filePaths, totalFiles, maxTotalSize, maxSize, validated, skippedBudget, signal, totalSize: startingTotalSize, } = options;
112
+ function applyBudgetForRange(options) {
113
+ const { batchStart, batchEnd, totalFiles, maxTotalSize, maxSize, validated, skippedBudget, totalSize: startingTotalSize, } = options;
114
114
  let totalSize = startingTotalSize;
115
115
  for (let index = batchStart; index < batchEnd; index += 1) {
116
- const filePath = filePaths[index];
117
- if (!filePath)
118
- continue;
119
- const cached = validated.get(index);
120
- const info = cached ??
121
- (await resolveValidatedInfo(filePath, index, validated, signal));
116
+ const info = validated.get(index);
122
117
  if (!info)
123
118
  continue;
124
119
  const { exceeded, totalSize: nextTotalSize } = applyBudget(totalSize, estimateReadSize(info.stats, maxSize), maxTotalSize, index, totalFiles, skippedBudget);
@@ -149,17 +144,15 @@ async function collectFileBudget(filePaths, maxTotalSize, maxSize, signal) {
149
144
  for (const [index, info] of batchInfos) {
150
145
  validated.set(index, info);
151
146
  }
152
- const budgetResult = await applyBudgetForRange({
147
+ const budgetResult = applyBudgetForRange({
153
148
  batchStart,
154
149
  batchEnd,
155
- filePaths,
156
150
  totalFiles,
157
151
  maxTotalSize,
158
152
  maxSize,
159
153
  validated,
160
154
  skippedBudget,
161
155
  totalSize,
162
- ...(signal ? { signal } : {}),
163
156
  });
164
157
  const { exceeded, totalSize: nextTotalSize } = budgetResult;
165
158
  if (exceeded) {
@@ -169,18 +162,6 @@ async function collectFileBudget(filePaths, maxTotalSize, maxSize, signal) {
169
162
  }
170
163
  return { skippedBudget, validated };
171
164
  }
172
- async function resolveValidatedInfo(filePath, index, validated, signal) {
173
- const existing = validated.get(index);
174
- if (existing) {
175
- return existing;
176
- }
177
- const info = await tryValidateFile(filePath, index, signal);
178
- if (info) {
179
- validated.set(index, info);
180
- return info;
181
- }
182
- return undefined;
183
- }
184
165
  function buildOutput(filePaths) {
185
166
  const output = new Array(filePaths.length);
186
167
  for (let index = 0; index < filePaths.length; index += 1) {
@@ -170,24 +170,21 @@ class ContextBuffer {
170
170
  snapshotBefore() {
171
171
  if (this.size === 0)
172
172
  return [];
173
- const result = [];
173
+ const result = new Array(this.size);
174
174
  if (this.size < this.capacity) {
175
175
  for (let i = 0; i < this.size; i++) {
176
- const item = this.buffer[i];
177
- if (item !== undefined)
178
- result.push(item);
176
+ result[i] = this.buffer[i] ?? '';
179
177
  }
180
178
  return result;
181
179
  }
180
+ let outIndex = 0;
182
181
  for (let i = this.head; i < this.capacity; i++) {
183
- const item = this.buffer[i];
184
- if (item !== undefined)
185
- result.push(item);
182
+ result[outIndex] = this.buffer[i] ?? '';
183
+ outIndex++;
186
184
  }
187
185
  for (let i = 0; i < this.head; i++) {
188
- const item = this.buffer[i];
189
- if (item !== undefined)
190
- result.push(item);
186
+ result[outIndex] = this.buffer[i] ?? '';
187
+ outIndex++;
191
188
  }
192
189
  return result;
193
190
  }
@@ -218,17 +215,13 @@ async function readMatches(handle, requestedPath, matcher, options, maxMatches,
218
215
  if (isCancelled())
219
216
  break;
220
217
  const matchCount = matcher(rawLine);
221
- let content;
222
- const getContent = () => {
223
- content ??= trimContent(rawLine);
224
- return content;
225
- };
218
+ const trimmedLine = hasContext || matchCount > 0 ? trimContent(rawLine) : '';
226
219
  if (matchCount > 0) {
227
220
  if (ctx) {
228
221
  matches.push({
229
222
  file: requestedPath,
230
223
  line: lineNumber,
231
- content: getContent(),
224
+ content: trimmedLine,
232
225
  matchCount,
233
226
  contextBefore: ctx.snapshotBefore(),
234
227
  contextAfter: ctx.scheduleAfter(),
@@ -238,13 +231,13 @@ async function readMatches(handle, requestedPath, matcher, options, maxMatches,
238
231
  matches.push({
239
232
  file: requestedPath,
240
233
  line: lineNumber,
241
- content: getContent(),
234
+ content: trimmedLine,
242
235
  matchCount,
243
236
  });
244
237
  }
245
238
  }
246
239
  if (ctx) {
247
- ctx.add(getContent());
240
+ ctx.add(trimmedLine);
248
241
  }
249
242
  lineNumber++;
250
243
  }
@@ -103,7 +103,7 @@ function extractResultError(structured) {
103
103
  ? err['message']
104
104
  : undefined;
105
105
  }
106
- function normalizePath(path) {
106
+ function sanitizePathForDiagnostics(path) {
107
107
  const { detail } = readConfig();
108
108
  if (!path || detail === 0)
109
109
  return undefined;
@@ -119,7 +119,7 @@ function enrichWithToolContext(detail) {
119
119
  if (!Object.hasOwn(merged, 'tool')) {
120
120
  merged.tool = current.tool;
121
121
  }
122
- const normalizedPath = normalizePath(current.path);
122
+ const normalizedPath = sanitizePathForDiagnostics(current.path);
123
123
  if (normalizedPath && !Object.hasOwn(merged, 'path')) {
124
124
  merged.path = normalizedPath;
125
125
  }
@@ -207,7 +207,7 @@ export function getToolContextSnapshot() {
207
207
  function normalizeContext(ctx) {
208
208
  if (!ctx.path)
209
209
  return ctx;
210
- const normalized = normalizePath(ctx.path);
210
+ const normalized = sanitizePathForDiagnostics(ctx.path);
211
211
  if (!normalized) {
212
212
  const copy = { ...ctx };
213
213
  delete copy.path;
@@ -318,7 +318,7 @@ async function runAndObserve(tool, run, pubTool, pubPerf, logErrors, pathVal) {
318
318
  }
319
319
  export async function withToolDiagnostics(tool, run, options) {
320
320
  const config = readConfig();
321
- const normalizedPath = normalizePath(options?.path);
321
+ const normalizedPath = sanitizePathForDiagnostics(options?.path);
322
322
  const context = {
323
323
  tool,
324
324
  ...(options?.path ? { path: options.path } : {}),
@@ -433,15 +433,28 @@ async function resolveRootDirectory(root, signal) {
433
433
  }
434
434
  }
435
435
  export async function getValidRootDirectories(roots, signal) {
436
+ const fileRoots = roots.filter(isFileRoot);
437
+ if (fileRoots.length === 0)
438
+ return [];
439
+ // Phase 1: Resolve all roots in parallel (order-preserving via index).
440
+ const resolvedResults = await Promise.all(fileRoots.map((root) => resolveRootDirectory(root, signal)));
441
+ const validPaths = resolvedResults.filter((p) => p !== null);
442
+ if (validPaths.length === 0)
443
+ return [];
444
+ // Phase 2: Expand real paths for each valid directory in parallel.
445
+ const realExpansions = await Promise.all(validPaths.map(async (normalizedPath) => {
446
+ const extra = [];
447
+ await maybeAddRealPath(normalizedPath, extra, signal);
448
+ return extra[0] ?? null;
449
+ }));
450
+ // Build output preserving insertion order: [normalizedPath, realPath?] per root.
436
451
  const validDirs = [];
437
- for (const root of roots) {
438
- if (!isFileRoot(root))
439
- continue;
440
- const normalizedPath = await resolveRootDirectory(root, signal);
441
- if (!normalizedPath)
442
- continue;
443
- validDirs.push(normalizedPath);
444
- await maybeAddRealPath(normalizedPath, validDirs, signal);
445
- }
452
+ validPaths.forEach((p, i) => {
453
+ validDirs.push(p);
454
+ const expanded = realExpansions[i];
455
+ if (expanded !== null && expanded !== undefined) {
456
+ validDirs.push(expanded);
457
+ }
458
+ });
446
459
  return validDirs;
447
460
  }
@@ -0,0 +1,6 @@
1
+ export declare const pkgInfo: {
2
+ name: string;
3
+ version: string;
4
+ description?: string | undefined;
5
+ homepage?: string | undefined;
6
+ };
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod';
2
+ import packageJsonRaw from '../package.json' with { type: 'json' };
3
+ const PkgInfoSchema = z.object({
4
+ name: z.string(),
5
+ version: z.string(),
6
+ description: z.string().optional(),
7
+ homepage: z.string().optional(),
8
+ });
9
+ export const pkgInfo = PkgInfoSchema.parse(packageJsonRaw);