@j0hanz/filesystem-mcp 1.7.3 → 1.9.0

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 (76) hide show
  1. package/README.md +635 -377
  2. package/dist/cli.js +2 -2
  3. package/dist/completions.js +2 -3
  4. package/dist/index.js +2 -2
  5. package/dist/lib/file-operations/{common.d.ts → core.d.ts} +6 -0
  6. package/dist/lib/file-operations/{common.js → core.js} +45 -0
  7. package/dist/lib/file-operations/metadata.d.ts +73 -0
  8. package/dist/lib/file-operations/metadata.js +889 -0
  9. package/dist/lib/file-operations/{search-content.d.ts → search.d.ts} +29 -3
  10. package/dist/lib/file-operations/{search-content.js → search.js} +409 -82
  11. package/dist/lib/file-operations/{glob-engine.d.ts → traversal.d.ts} +18 -1
  12. package/dist/lib/file-operations/{glob-engine.js → traversal.js} +25 -2
  13. package/dist/lib/fs-helpers.d.ts +1 -0
  14. package/dist/lib/fs-helpers.js +10 -2
  15. package/dist/lib/observability.js +1 -1
  16. package/dist/lib/{path-validation.d.ts → paths.d.ts} +4 -0
  17. package/dist/lib/{path-validation.js → paths.js} +112 -1
  18. package/dist/lib/utils.d.ts +15 -0
  19. package/dist/lib/utils.js +33 -0
  20. package/dist/resources/generated-instructions.js +6 -6
  21. package/dist/resources/tool-catalog.js +9 -9
  22. package/dist/resources/tool-info.js +3 -3
  23. package/dist/resources/workflows.js +10 -23
  24. package/dist/schemas.js +15 -15
  25. package/dist/server/bootstrap.d.ts +19 -1
  26. package/dist/server/bootstrap.js +103 -9
  27. package/dist/server/roots-manager.d.ts +2 -2
  28. package/dist/server/roots-manager.js +3 -3
  29. package/dist/tools/apply-patch.js +7 -3
  30. package/dist/tools/calculate-hash.js +15 -15
  31. package/dist/tools/create-directory.js +1 -1
  32. package/dist/tools/delete-file.js +7 -4
  33. package/dist/tools/diff-files.js +2 -2
  34. package/dist/tools/edit-file.js +14 -13
  35. package/dist/tools/list-directory.js +5 -7
  36. package/dist/tools/move-file.js +2 -1
  37. package/dist/tools/read-multiple.js +13 -26
  38. package/dist/tools/read.js +3 -3
  39. package/dist/tools/replace-in-files.js +17 -20
  40. package/dist/tools/roots.js +4 -6
  41. package/dist/tools/search-content.js +9 -11
  42. package/dist/tools/search-files.js +4 -6
  43. package/dist/tools/shared.d.ts +18 -1
  44. package/dist/tools/shared.js +39 -19
  45. package/dist/tools/stat-many.js +14 -24
  46. package/dist/tools/stat.js +4 -3
  47. package/dist/tools/task-support.js +1 -1
  48. package/dist/tools/tree.js +3 -4
  49. package/dist/tools/write-file.js +1 -1
  50. package/package.json +9 -5
  51. package/dist/lib/file-operations/file-info.d.ts +0 -10
  52. package/dist/lib/file-operations/file-info.js +0 -143
  53. package/dist/lib/file-operations/gitignore.d.ts +0 -6
  54. package/dist/lib/file-operations/gitignore.js +0 -45
  55. package/dist/lib/file-operations/list-directory.d.ts +0 -14
  56. package/dist/lib/file-operations/list-directory.js +0 -256
  57. package/dist/lib/file-operations/read-multiple-files.d.ts +0 -25
  58. package/dist/lib/file-operations/read-multiple-files.js +0 -252
  59. package/dist/lib/file-operations/search-files.d.ts +0 -27
  60. package/dist/lib/file-operations/search-files.js +0 -218
  61. package/dist/lib/file-operations/search-worker.d.ts +0 -2
  62. package/dist/lib/file-operations/search-worker.js +0 -129
  63. package/dist/lib/file-operations/tree.d.ts +0 -28
  64. package/dist/lib/file-operations/tree.js +0 -269
  65. package/dist/lib/path-format.d.ts +0 -1
  66. package/dist/lib/path-format.js +0 -7
  67. package/dist/lib/path-policy.d.ts +0 -2
  68. package/dist/lib/path-policy.js +0 -100
  69. package/dist/lib/type-guards.d.ts +0 -1
  70. package/dist/lib/type-guards.js +0 -3
  71. package/dist/server/capabilities.d.ts +0 -10
  72. package/dist/server/capabilities.js +0 -48
  73. package/dist/server/logging.d.ts +0 -7
  74. package/dist/server/logging.js +0 -41
  75. package/dist/server/types.d.ts +0 -4
  76. package/dist/server/types.js +0 -1
@@ -6,19 +6,104 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  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
- import { registerCompletions } from '../completions.js';
10
9
  import { DEFAULT_LOG_LEVEL, parseEnvInt, REQUIRED_MCP_PROTOCOL_VERSION, } from '../lib/constants.js';
11
10
  import { formatUnknownErrorMessage } from '../lib/errors.js';
12
11
  import { createInMemoryResourceStore } from '../lib/resource-store.js';
12
+ import { isRecord } from '../lib/utils.js';
13
+ import { registerCompletions } from '../completions.js';
13
14
  import { pkgInfo } from '../pkg-info.js';
14
15
  import { registerGetHelpPrompt } from '../prompts.js';
15
16
  import { registerInstructionResource, registerMetricsResource, registerResultResources, registerToolCatalogResource, registerToolInfoResource, registerWorkflowGuideResource, } from '../resources.js';
16
17
  import { buildServerInstructions } from '../resources/generated-instructions.js';
17
18
  import { registerAllTools } from '../tools.js';
18
19
  import { withDefaultIcons } from '../tools/shared.js';
19
- import { buildServerCapabilities, supportsTaskToolRequests, } from './capabilities.js';
20
- import { createLoggingState } from './logging.js';
21
20
  import { RootsManager } from './roots-manager.js';
21
+ let cachedTaskToolSupport;
22
+ function detectTaskToolSupport() {
23
+ if (cachedTaskToolSupport !== undefined) {
24
+ return cachedTaskToolSupport;
25
+ }
26
+ try {
27
+ // Instantiate a minimal, unconnected probe server to duck-type check for
28
+ // task tool support. The probe has no transport or active connections, so
29
+ // close() only releases in-memory state; fire-and-forget is safe here.
30
+ const probe = new McpServer({
31
+ name: 'filesystem-mcp-capability-probe',
32
+ version: '0.0.0',
33
+ }, { capabilities: { tools: {} } });
34
+ cachedTaskToolSupport =
35
+ typeof probe.experimental.tasks.registerToolTask === 'function';
36
+ probe.close().catch(() => { });
37
+ }
38
+ catch {
39
+ cachedTaskToolSupport = false;
40
+ }
41
+ return cachedTaskToolSupport;
42
+ }
43
+ export function buildServerCapabilities(options = {}) {
44
+ const capabilities = {
45
+ logging: {},
46
+ resources: {},
47
+ tools: {},
48
+ prompts: options.enablePromptListChanged ? { listChanged: true } : {},
49
+ completions: {},
50
+ };
51
+ if (options.enableTaskToolRequests) {
52
+ // NOTE: enabling task tool requests requires the caller to configure
53
+ // an InMemoryTaskStore and InMemoryTaskMessageQueue on the McpServer.
54
+ // InMemoryTaskStore accumulates completed task records with no TTL eviction —
55
+ // suitable for short-lived stdio sessions. Long-running HTTP servers should
56
+ // replace it with a TTL-evicting store to avoid unbounded memory growth.
57
+ capabilities.tasks = {
58
+ list: {},
59
+ cancel: {},
60
+ requests: { tools: { call: {} } },
61
+ };
62
+ }
63
+ return capabilities;
64
+ }
65
+ export function supportsTaskToolRequests() {
66
+ return detectTaskToolSupport();
67
+ }
68
+ const MCP_LOGGER_NAME = 'filesystem-mcp';
69
+ const LOG_LEVEL_ORDER = {
70
+ debug: 0,
71
+ info: 1,
72
+ notice: 2,
73
+ warning: 3,
74
+ error: 4,
75
+ critical: 5,
76
+ alert: 6,
77
+ emergency: 7,
78
+ };
79
+ export function createLoggingState(minimumLevel = 'debug') {
80
+ return { minimumLevel };
81
+ }
82
+ function canSendMcpLogs(server) {
83
+ const capabilities = server.server.getClientCapabilities();
84
+ if (!isRecord(capabilities))
85
+ return false;
86
+ if (!('logging' in capabilities))
87
+ return false;
88
+ return !!capabilities['logging'];
89
+ }
90
+ export function logToMcp(server, level, data, minLevel = 'debug') {
91
+ if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[minLevel]) {
92
+ return;
93
+ }
94
+ if (!server || !canSendMcpLogs(server)) {
95
+ console.error(data);
96
+ return;
97
+ }
98
+ const params = {
99
+ level,
100
+ logger: MCP_LOGGER_NAME,
101
+ data,
102
+ };
103
+ void server.sendLoggingMessage(params).catch((error) => {
104
+ console.error(`Failed to send MCP log: ${level} | ${data}`, formatUnknownErrorMessage(error));
105
+ });
106
+ }
22
107
  const { version: SERVER_VERSION, description: SERVER_DESCRIPTION, homepage: SERVER_HOMEPAGE, } = pkgInfo;
23
108
  const rootsManagers = new WeakMap();
24
109
  function getRootsManager(server) {
@@ -242,9 +327,11 @@ export async function startHttpServer(port, options) {
242
327
  if (typeof authHeader === 'string' &&
243
328
  authHeader.startsWith(bearerPrefix)) {
244
329
  const userKey = authHeader.slice(bearerPrefix.length);
245
- const expectedHash = createHash('sha256').update(apiKey).digest();
246
- const actualHash = createHash('sha256').update(userKey).digest();
247
- authorized = timingSafeEqual(expectedHash, actualHash);
330
+ if (userKey.length <= 4096) {
331
+ const expectedHash = createHash('sha256').update(apiKey).digest();
332
+ const actualHash = createHash('sha256').update(userKey).digest();
333
+ authorized = timingSafeEqual(expectedHash, actualHash);
334
+ }
248
335
  }
249
336
  if (!authorized) {
250
337
  res.writeHead(401, {
@@ -283,7 +370,7 @@ export async function startHttpServer(port, options) {
283
370
  }
284
371
  const body = await readRequestBody(req);
285
372
  if (isInitializeRequest(body)) {
286
- const maxSessions = parseInt(process.env['FILESYSTEM_MCP_MAX_HTTP_SESSIONS'] ?? '', 10) || 100;
373
+ const maxSessions = parseEnvInt('FILESYSTEM_MCP_MAX_HTTP_SESSIONS', 100, 1, 10_000);
287
374
  if (sessions.size >= maxSessions) {
288
375
  sendJsonRpcError(res, 503, -32000, 'Too many sessions');
289
376
  return;
@@ -316,8 +403,15 @@ export async function startHttpServer(port, options) {
316
403
  }
317
404
  }
318
405
  else {
319
- res.writeHead(405, { Allow: 'GET, POST, DELETE' });
320
- res.end('Method Not Allowed');
406
+ res.writeHead(405, {
407
+ Allow: 'GET, POST, DELETE',
408
+ 'Content-Type': 'application/json',
409
+ });
410
+ res.end(JSON.stringify({
411
+ jsonrpc: '2.0',
412
+ error: { code: -32000, message: 'Method Not Allowed' },
413
+ id: null,
414
+ }));
321
415
  }
322
416
  }
323
417
  catch (error) {
@@ -1,6 +1,6 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { type LoggingState } from './logging.js';
3
- import type { ServerOptions } from './types.js';
2
+ import { type LoggingState } from './bootstrap.js';
3
+ import type { ServerOptions } from './bootstrap.js';
4
4
  export declare class RootsManager {
5
5
  private rootsUpdateTimeout;
6
6
  private rootDirectories;
@@ -3,9 +3,9 @@ import { InitializedNotificationSchema, RootsListChangedNotificationSchema, } fr
3
3
  import { z } from 'zod';
4
4
  import { formatUnknownErrorMessage } from '../lib/errors.js';
5
5
  import { assertNotAborted, createTimedAbortSignal, withAbort, } from '../lib/fs-helpers.js';
6
- import { getAllowedDirectories, getValidRootDirectories, isPathWithinDirectories, normalizePath, setAllowedDirectoriesResolved, } from '../lib/path-validation.js';
7
- import { isRecord } from '../lib/type-guards.js';
8
- import { logToMcp } from './logging.js';
6
+ import { getAllowedDirectories, getValidRootDirectories, isPathWithinDirectories, normalizePath, setAllowedDirectoriesResolved, } from '../lib/paths.js';
7
+ import { isRecord } from '../lib/utils.js';
8
+ import { logToMcp } from './bootstrap.js';
9
9
  const ROOTS_TIMEOUT_MS = 5000;
10
10
  const ROOTS_DEBOUNCE_MS = 100;
11
11
  function normalizeCLIDirectories(dirs) {
@@ -4,7 +4,7 @@ import { applyPatch } from 'diff';
4
4
  import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
5
5
  import { ErrorCode, McpError } from '../lib/errors.js';
6
6
  import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
7
- import { validateExistingPath } from '../lib/path-validation.js';
7
+ import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
8
8
  import { ApplyPatchInputSchema, ApplyPatchOutputSchema } from '../schemas.js';
9
9
  import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
10
10
  import { registerToolTaskIfAvailable } from './task-support.js';
@@ -12,8 +12,8 @@ export const APPLY_PATCH_TOOL = {
12
12
  name: 'apply_patch',
13
13
  title: 'Apply Patch',
14
14
  description: 'Apply a unified diff patch to a file. ' +
15
- 'Generate the patch with `diff_files`, then validate with `dryRun: true` before writing. ' +
16
- 'On failure, regenerate a fresh patch via `diff_files` against the current file content and retry.',
15
+ 'Workflow: `diff_files` \u2192 `apply_patch(dryRun:true)` \u2192 `apply_patch`. ' +
16
+ 'On failure, regenerate the patch from current file content.',
17
17
  inputSchema: ApplyPatchInputSchema,
18
18
  outputSchema: ApplyPatchOutputSchema,
19
19
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
@@ -36,6 +36,7 @@ function assertPatchHasHunks(patch) {
36
36
  async function handleApplyPatch(args, signal) {
37
37
  const maxFileSize = MAX_TEXT_FILE_SIZE;
38
38
  const validPath = await validateExistingPath(args.path, signal);
39
+ assertAllowedFileAccess(args.path, validPath);
39
40
  const stats = await withAbort(fs.stat(validPath), signal);
40
41
  assertPatchTargetSizeWithinLimit(validPath, stats.size, maxFileSize);
41
42
  const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
@@ -48,6 +49,9 @@ async function handleApplyPatch(args, signal) {
48
49
  if (patched === false) {
49
50
  throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch application failed. The file content may have changed or patch context is insufficient. Generate a fresh patch via diff_files against the current file, then retry. If differences are minor, enable fuzzy matching with the fuzzFactor parameter.');
50
51
  }
52
+ if (patched === content) {
53
+ throw new McpError(ErrorCode.E_INVALID_INPUT, 'Patch had no effect — the file content is unchanged after applying. The patch may not match the current file content. Generate a fresh patch via diff_files and retry.');
54
+ }
51
55
  if (args.dryRun) {
52
56
  return buildToolResponse('Dry run successful. Patch can be applied.', {
53
57
  ok: true,
@@ -4,12 +4,13 @@ import { createHash } from 'node:crypto';
4
4
  import { createReadStream } from 'node:fs';
5
5
  import { PARALLEL_CONCURRENCY } from '../lib/constants.js';
6
6
  import { ErrorCode } from '../lib/errors.js';
7
- import { isIgnoredByGitignore, loadRootGitignore, } from '../lib/file-operations/gitignore.js';
8
- import { globEntries } from '../lib/file-operations/glob-engine.js';
7
+ import { isIgnoredByGitignore, loadRootGitignore, } from '../lib/file-operations/core.js';
8
+ import { globEntries } from '../lib/file-operations/traversal.js';
9
9
  import { assertNotAborted, withAbort } from '../lib/fs-helpers.js';
10
- import { validateExistingPath } from '../lib/path-validation.js';
10
+ import { validateExistingPath } from '../lib/paths.js';
11
+ import { reportPeriodicProgress } from '../lib/utils.js';
11
12
  import { CalculateHashInputSchema, CalculateHashOutputSchema, } from '../schemas.js';
12
- import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
13
+ import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
13
14
  import { registerToolTaskIfAvailable } from './task-support.js';
14
15
  const WINDOWS_PATH_SEPARATOR = /\\/gu;
15
16
  export const CALCULATE_HASH_TOOL = {
@@ -54,13 +55,6 @@ function updateCompositeHash(hasher, pathLengthBytes, relativePath, fileHash) {
54
55
  hasher.update(relativePathBytes);
55
56
  hasher.update(fileHash);
56
57
  }
57
- function reportHashProgress(onProgress, current, force = false) {
58
- if (!onProgress || current === 0)
59
- return;
60
- if (!force && current % 25 !== 0)
61
- return;
62
- onProgress({ current });
63
- }
64
58
  async function hashDirectory(dirPath, options = {}) {
65
59
  const { signal, onProgress } = options;
66
60
  const gitignoreMatcher = await loadRootGitignore(dirPath, signal);
@@ -102,9 +96,12 @@ async function hashDirectory(dirPath, options = {}) {
102
96
  }));
103
97
  entries.push(...batchResults);
104
98
  filesHashed += batchResults.length;
105
- reportHashProgress(onProgress, filesHashed);
99
+ reportPeriodicProgress(onProgress, filesHashed, { throttleModulo: 25 });
106
100
  }
107
- reportHashProgress(onProgress, filesHashed, true);
101
+ reportPeriodicProgress(onProgress, filesHashed, {
102
+ throttleModulo: 25,
103
+ force: true,
104
+ });
108
105
  assertNotAborted(signal);
109
106
  // Sort by path with byte-wise semantics for deterministic ordering.
110
107
  entries.sort(comparePaths);
@@ -141,7 +138,10 @@ async function handleCalculateHash(args, signal, onProgress) {
141
138
  else {
142
139
  // Hash single file
143
140
  const hash = await hashFile(validPath, 'hex', signal);
144
- reportHashProgress(onProgress, 1, true);
141
+ reportPeriodicProgress(onProgress, 1, {
142
+ throttleModulo: 25,
143
+ force: true,
144
+ });
145
145
  return buildToolResponse(hash, {
146
146
  ok: true,
147
147
  path: validPath,
@@ -171,7 +171,7 @@ export function registerCalculateHashTool(server, options = {}) {
171
171
  const result = await handleCalculateHash(args, signal, progressWithMessage);
172
172
  const sc = result.structuredContent;
173
173
  const totalFiles = sc.ok ? (sc.fileCount ?? 1) : 1;
174
- const finalCurrent = Math.max(totalFiles + 1, progress.getCurrent() + 1);
174
+ const finalCurrent = resolveFinalProgressCurrent(progress, totalFiles + 1);
175
175
  let suffix;
176
176
  if (!sc.ok) {
177
177
  suffix = 'failed';
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import { ErrorCode, McpError } from '../lib/errors.js';
3
3
  import { withAbort } from '../lib/fs-helpers.js';
4
- import { validatePathForWrite } from '../lib/path-validation.js';
4
+ import { validatePathForWrite } from '../lib/paths.js';
5
5
  import { CreateDirectoryInputSchema, CreateDirectoryOutputSchema, } from '../schemas.js';
6
6
  import { buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, IDEMPOTENT_WRITE_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
7
  import { registerToolTaskIfAvailable } from './task-support.js';
@@ -1,8 +1,8 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
- import { ErrorCode, isNodeError } from '../lib/errors.js';
3
+ import { ErrorCode, isNodeError, McpError } from '../lib/errors.js';
4
4
  import { withAbort } from '../lib/fs-helpers.js';
5
- import { validatePathForWrite } from '../lib/path-validation.js';
5
+ import { isAllowedDirectoryRoot, validatePathForWrite } from '../lib/paths.js';
6
6
  import { DeleteFileInputSchema, DeleteFileOutputSchema } from '../schemas.js';
7
7
  import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
8
  import { registerToolTaskIfAvailable } from './task-support.js';
@@ -14,12 +14,15 @@ export const DELETE_FILE_TOOL = {
14
14
  outputSchema: DeleteFileOutputSchema,
15
15
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
16
16
  gotchas: [
17
- 'Deletion is permanent there is no undo or recycle bin.',
18
- 'Non-empty directory delete requires `recursive=true`; else returns actionable input error.',
17
+ 'No undodeletion is permanent.',
18
+ 'Non-empty directories require `recursive=true`.',
19
19
  ],
20
20
  };
21
21
  async function handleDeleteFile(args, signal) {
22
22
  const validPath = await validatePathForWrite(args.path, signal);
23
+ if (isAllowedDirectoryRoot(validPath)) {
24
+ throw new McpError(ErrorCode.E_ACCESS_DENIED, `Deleting a workspace root directory is not allowed: ${args.path}`);
25
+ }
23
26
  let stats;
24
27
  try {
25
28
  stats = await withAbort(fs.lstat(validPath), signal);
@@ -4,7 +4,7 @@ import { createTwoFilesPatch } from 'diff';
4
4
  import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
5
5
  import { ErrorCode, McpError } from '../lib/errors.js';
6
6
  import { withAbort } from '../lib/fs-helpers.js';
7
- import { validateExistingPath } from '../lib/path-validation.js';
7
+ import { validateExistingPath } from '../lib/paths.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
10
  import { registerToolTaskIfAvailable } from './task-support.js';
@@ -13,7 +13,7 @@ export const DIFF_FILES_TOOL = {
13
13
  title: 'Diff Files',
14
14
  description: 'Generate a unified diff between two files. ' +
15
15
  'Output feeds directly into `apply_patch`. ' +
16
- 'Check `isIdentical` in the response — if true, the files are already in sync and no patch is needed.',
16
+ '`isIdentical=true` means files match \u2014 no patch needed.',
17
17
  inputSchema: DiffFilesInputSchema,
18
18
  outputSchema: DiffFilesOutputSchema,
19
19
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
@@ -1,28 +1,24 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import RE2 from 're2';
4
- import { ErrorCode } from '../lib/errors.js';
5
- import { atomicWriteFile } from '../lib/fs-helpers.js';
6
- import { validateExistingPath } from '../lib/path-validation.js';
4
+ import { MAX_TEXT_FILE_SIZE } from '../lib/constants.js';
5
+ import { ErrorCode, McpError } from '../lib/errors.js';
6
+ import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
7
+ import { assertAllowedFileAccess, validateExistingPath } from '../lib/paths.js';
7
8
  import { EditFileInputSchema, EditFileOutputSchema } from '../schemas.js';
8
9
  import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
9
10
  import { registerToolTaskIfAvailable } from './task-support.js';
10
11
  export const EDIT_FILE_TOOL = {
11
12
  name: 'edit',
12
13
  title: 'Edit File',
13
- description: 'Edit a file by replacing text. Sequentially applies a list of string replacements. ' +
14
- 'Replaces the first occurrence of each `oldText`. ' +
15
- '`oldText` must match exactly — include 3–5 lines of surrounding context to uniquely target the location. ' +
16
- 'Use `dryRun: true` to validate edits before writing.',
14
+ description: 'Apply sequential literal string replacements to a file (first occurrence per edit). ' +
15
+ '`oldText` must match exactly \u2014 include 3\u20135 lines of context for unique targeting. ' +
16
+ 'Use `dryRun:true` to preview.',
17
17
  inputSchema: EditFileInputSchema,
18
18
  outputSchema: EditFileOutputSchema,
19
19
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
20
- nuances: [
21
- 'Apply sequential literal replacements (first occurrence per edit).',
22
- ],
23
- gotchas: [
24
- '`oldText` must match exactly; unmatched items are reported in `unmatchedEdits`.',
25
- ],
20
+ nuances: ['Each edit applies to the output of the previous edit.'],
21
+ gotchas: ['Unmatched `oldText` entries listed in `unmatchedEdits`.'],
26
22
  };
27
23
  function escapeRegExp(string) {
28
24
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -88,6 +84,11 @@ function applyEdits(content, edits, ignoreWhitespace) {
88
84
  }
89
85
  export async function handleEditFile(args, signal) {
90
86
  const validPath = await validateExistingPath(args.path, signal);
87
+ assertAllowedFileAccess(args.path, validPath);
88
+ const stats = await withAbort(fs.stat(validPath), signal);
89
+ if (stats.size > MAX_TEXT_FILE_SIZE) {
90
+ throw new McpError(ErrorCode.E_TOO_LARGE, `File too large for edit: ${args.path} (${stats.size} bytes > ${MAX_TEXT_FILE_SIZE} bytes)`, args.path, { size: stats.size, maxFileSize: MAX_TEXT_FILE_SIZE });
91
+ }
91
92
  const content = await fs.readFile(validPath, { encoding: 'utf-8', signal });
92
93
  const { content: newContent, appliedEdits, unmatchedEdits, lineRange, } = applyEdits(content, args.edits, args.ignoreWhitespace);
93
94
  const structured = {
@@ -1,19 +1,17 @@
1
1
  import * as path from 'node:path';
2
- import { formatOperationSummary, joinLines } from '../config.js';
3
2
  import { DEFAULT_EXCLUDE_PATTERNS } from '../lib/constants.js';
4
3
  import { ErrorCode } from '../lib/errors.js';
5
- import { listDirectory } from '../lib/file-operations/list-directory.js';
4
+ import { listDirectory } from '../lib/file-operations/metadata.js';
5
+ import { formatOperationSummary, joinLines } from '../config.js';
6
6
  import { ListDirectoryInputSchema, ListDirectoryOutputSchema, } from '../schemas.js';
7
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',
11
11
  title: 'List Directory',
12
- description: 'List the immediate contents of a directory (non-recursive). ' +
13
- 'Returns name, relative path, type (file/directory/symlink), size, and modified date. ' +
14
- 'Omit path to list the workspace root. ' +
15
- 'Use includeIgnored=true to include ignored directories like node_modules. ' +
16
- 'For recursive searches, use find instead.',
12
+ description: 'List immediate directory contents (non-recursive): name, path, type, size, modified date. ' +
13
+ 'Omit path for workspace root. `includeIgnored=true` for node_modules etc. ' +
14
+ 'For recursive search, use `find`.',
17
15
  inputSchema: ListDirectoryInputSchema,
18
16
  outputSchema: ListDirectoryOutputSchema,
19
17
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
@@ -2,7 +2,7 @@ import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import { ErrorCode, formatUnknownErrorMessage, isNodeError, McpError, } from '../lib/errors.js';
4
4
  import { withAbort } from '../lib/fs-helpers.js';
5
- import { validateExistingPath, validatePathForWrite, } from '../lib/path-validation.js';
5
+ import { assertAllowedFileAccess, validateExistingPath, validatePathForWrite, } from '../lib/paths.js';
6
6
  import { MoveFileInputSchema, MoveFileOutputSchema } from '../schemas.js';
7
7
  import { buildToolErrorResponse, buildToolResponse, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
8
8
  import { registerToolTaskIfAvailable } from './task-support.js';
@@ -48,6 +48,7 @@ export async function handleMoveFile(args, signal) {
48
48
  let validSource;
49
49
  try {
50
50
  validSource = await validateExistingPath(src, signal);
51
+ assertAllowedFileAccess(src, validSource);
51
52
  }
52
53
  catch (error) {
53
54
  failed.push({
@@ -1,16 +1,15 @@
1
1
  import * as path from 'node:path';
2
2
  import { DEFAULT_READ_MANY_MAX_TOTAL_SIZE, DEFAULT_SEARCH_TIMEOUT_MS, } from '../lib/constants.js';
3
3
  import { ErrorCode } from '../lib/errors.js';
4
- import { readMultipleFiles } from '../lib/file-operations/read-multiple-files.js';
4
+ import { readMultipleFiles } from '../lib/file-operations/metadata.js';
5
5
  import { ReadMultipleFilesInputSchema, ReadMultipleFilesOutputSchema, } from '../schemas.js';
6
- import { buildBatchPathContext, buildResourceLink, buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
6
+ import { buildBatchCompletionSuffix, buildBatchPathContext, buildResourceLink, buildToolErrorResponse, buildToolResponse, createBatchProgressCallbacks, executeToolWithDiagnostics, maybeExternalizeTextContent, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, 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',
10
10
  title: 'Read Multiple Files',
11
- description: 'Read multiple text files in a single request. ' +
12
- 'Returns contents and metadata for each file. ' +
13
- 'For single file, use read for simpler output.',
11
+ description: 'Read multiple text files in one request with contents and metadata. ' +
12
+ 'For a single file, use `read`.',
14
13
  inputSchema: ReadMultipleFilesInputSchema,
15
14
  outputSchema: ReadMultipleFilesOutputSchema,
16
15
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
@@ -20,23 +19,6 @@ export const READ_MULTIPLE_FILES_TOOL = {
20
19
  'Per-file `truncationReason` can be `head`, `range`, or `externalized`.',
21
20
  ],
22
21
  };
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
- }
40
22
  function toStructuredReadManyResult(result) {
41
23
  const structured = {
42
24
  path: result.path,
@@ -152,13 +134,18 @@ export function registerReadMultipleFilesTool(server, options = {}) {
152
134
  context: { path: primaryPath },
153
135
  run: async (signal) => {
154
136
  const context = buildBatchPathContext(args.paths, 'files');
155
- const { progress, onReadComplete } = createReadManyProgressCallbacks(extra, context, args.paths.length);
137
+ const { progress, onItemComplete } = createBatchProgressCallbacks(extra, {
138
+ toolLabel: '🕮 read_many',
139
+ context,
140
+ totalItems: args.paths.length,
141
+ itemVerb: 'read',
142
+ });
156
143
  try {
157
- const result = await handleReadMultipleFiles(args, signal, options.resourceStore, onReadComplete);
144
+ const result = await handleReadMultipleFiles(args, signal, options.resourceStore, onItemComplete);
158
145
  const sc = result.structuredContent;
159
- const suffix = buildReadManyCompletionSuffix(sc.summary);
146
+ const suffix = buildBatchCompletionSuffix(sc.summary, 'files read', 'file read');
160
147
  const total = sc.summary?.total ?? 0;
161
- const finalCurrent = Math.max(total, progress.getCurrent() + 1);
148
+ const finalCurrent = resolveFinalProgressCurrent(progress, total);
162
149
  progress.complete(`🕮 read_many: ${context} • ${suffix}`, finalCurrent);
163
150
  return result;
164
151
  }
@@ -8,9 +8,9 @@ import { registerToolTaskIfAvailable } from './task-support.js';
8
8
  export const READ_FILE_TOOL = {
9
9
  name: 'read',
10
10
  title: 'Read File',
11
- description: 'Read the text contents of a file. ' +
12
- 'Use head parameter to preview the first N lines of large files. ' +
13
- 'For multiple files, use read_many for efficiency.',
11
+ description: 'Read text file contents. ' +
12
+ 'Use `head` to preview first N lines of large files. ' +
13
+ 'For multiple files, use `read_many`.',
14
14
  inputSchema: ReadFileInputSchema,
15
15
  outputSchema: ReadFileOutputSchema,
16
16
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
@@ -5,27 +5,26 @@ import RE2 from 're2';
5
5
  import safeRegex from 'safe-regex2';
6
6
  import { DEFAULT_EXCLUDE_PATTERNS, MAX_TEXT_FILE_SIZE, PARALLEL_CONCURRENCY, } from '../lib/constants.js';
7
7
  import { ErrorCode, formatUnknownErrorMessage, McpError, } from '../lib/errors.js';
8
- import { globEntries } from '../lib/file-operations/glob-engine.js';
8
+ import { globEntries } from '../lib/file-operations/traversal.js';
9
9
  import { atomicWriteFile, withAbort } from '../lib/fs-helpers.js';
10
- import { validateExistingPath, validatePathForWrite, } from '../lib/path-validation.js';
10
+ import { validateExistingPath, validatePathForWrite } from '../lib/paths.js';
11
+ import { reportPeriodicProgress } from '../lib/utils.js';
11
12
  import { SearchAndReplaceInputSchema, SearchAndReplaceOutputSchema, } from '../schemas.js';
12
- import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
13
+ import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, resolveFinalProgressCurrent, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
13
14
  import { registerToolTaskIfAvailable } from './task-support.js';
14
15
  export const SEARCH_AND_REPLACE_TOOL = {
15
16
  name: 'search_and_replace',
16
17
  title: 'Search and Replace',
17
- description: 'Search and replace text across multiple files matching a glob pattern. ' +
18
- 'Replaces ALL occurrences in each file (unlike `edit` which replaces only the first). ' +
19
- 'Use `filePattern` to scope which files are touched. ' +
20
- 'Always run with `dryRun: true` first to verify matches before writing. ' +
21
- 'Returns a unified diff of changes in `dryRun` mode. ' +
22
- 'Literal mode (default) matches exact text; `isRegex: true` enables RE2 regex with capture groups ($1, $2).',
18
+ description: 'Bulk search-and-replace across files matching a glob. ' +
19
+ 'Replaces ALL occurrences per file (unlike `edit`: first only). ' +
20
+ 'Always `dryRun:true` first \u2014 returns a unified diff. ' +
21
+ 'Literal matching by default; `isRegex:true` enables RE2 with capture groups ($1, $2).',
23
22
  inputSchema: SearchAndReplaceInputSchema,
24
23
  outputSchema: SearchAndReplaceOutputSchema,
25
24
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
26
25
  taskSupport: 'optional',
27
26
  gotchas: [
28
- 'Literal mode is default; `isRegex=true` enables RE2 + capture replacements (`$1`, `$2`).',
27
+ 'Replaces ALL occurrences not just the first. Use `edit` for single replacements.',
29
28
  ],
30
29
  nuances: [
31
30
  'Changed-file sample and failure sample are capped/truncated in output.',
@@ -204,13 +203,6 @@ function createReplacementMatcher(args) {
204
203
  }
205
204
  return createLiteralReplacementMatcher(args.searchPattern);
206
205
  }
207
- function reportReplaceProgress(onProgress, current, force = false) {
208
- if (current === 0)
209
- return;
210
- if (!force && current % 25 !== 0)
211
- return;
212
- onProgress({ current });
213
- }
214
206
  export async function handleSearchAndReplace(args, signal, onProgress = () => { }) {
215
207
  const maxFileSize = MAX_TEXT_FILE_SIZE;
216
208
  const root = await resolveSearchRoot(args.path, signal);
@@ -233,14 +225,19 @@ export async function handleSearchAndReplace(args, signal, onProgress = () => {
233
225
  concurrency: REPLACE_CONCURRENCY,
234
226
  onEntry: () => {
235
227
  summary.processedFiles++;
236
- reportReplaceProgress(onProgress, summary.processedFiles);
228
+ reportPeriodicProgress(onProgress, summary.processedFiles, {
229
+ throttleModulo: 25,
230
+ });
237
231
  },
238
232
  runEntry: async (entryPath) => processEntry(entryPath, {
239
233
  dryRun: args.dryRun,
240
234
  returnDiff: args.returnDiff ?? false,
241
235
  }, args.replacement, matcher, maxFileSize, signal, summary),
242
236
  });
243
- reportReplaceProgress(onProgress, summary.processedFiles, true);
237
+ reportPeriodicProgress(onProgress, summary.processedFiles, {
238
+ throttleModulo: 25,
239
+ force: true,
240
+ });
244
241
  const failureSuffix = summary.failedFiles > 0 ? ` (${summary.failedFiles} failed)` : '';
245
242
  return buildToolResponse(`Found ${summary.totalMatches} matches in ${summary.filesChanged} files${failureSuffix}.${args.dryRun ? ' (Dry run)' : ''}`, {
246
243
  ok: true,
@@ -279,7 +276,7 @@ export function registerSearchAndReplaceTool(server, options = {}) {
279
276
  try {
280
277
  const result = await handleSearchAndReplace(args, signal, progressWithMessage);
281
278
  const sc = result.structuredContent;
282
- const finalCurrent = Math.max((sc.processedFiles ?? 0) + 1, progress.getCurrent() + 1);
279
+ const finalCurrent = resolveFinalProgressCurrent(progress, (sc.processedFiles ?? 0) + 1);
283
280
  const matchWord = (sc.matches ?? 0) === 1 ? 'match' : 'matches';
284
281
  const fileWord = (sc.filesChanged ?? 0) === 1 ? 'file' : 'files';
285
282
  let endSuffix = `${sc.matches ?? 0} ${matchWord} in ${sc.filesChanged ?? 0} ${fileWord}`;
@@ -1,19 +1,17 @@
1
- import { joinLines } from '../config.js';
2
1
  import { ErrorCode } from '../lib/errors.js';
3
- import { getAllowedDirectories } from '../lib/path-validation.js';
2
+ import { getAllowedDirectories } from '../lib/paths.js';
3
+ import { joinLines } from '../config.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
6
  import { registerToolTaskIfAvailable } from './task-support.js';
7
7
  export const LIST_ALLOWED_DIRECTORIES_TOOL = {
8
8
  name: 'roots',
9
9
  title: 'Workspace Roots',
10
- description: 'List the workspace roots this server can access. ' +
11
- 'Call this first to see available directories. ' +
12
- 'All other tools only work within these directories.',
10
+ description: 'List allowed workspace roots. Call first \u2014 all other tools are scoped to these directories.',
13
11
  inputSchema: ListAllowedDirectoriesInputSchema,
14
12
  outputSchema: ListAllowedDirectoriesOutputSchema,
15
13
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
16
- nuances: ['Other tools are constrained to these roots.'],
14
+ nuances: ['Returns absolute paths of all allowed directories.'],
17
15
  };
18
16
  function buildTextRoots(dirs) {
19
17
  if (dirs.length === 0) {