@j0hanz/filesystem-mcp 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) 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} +40 -7
  10. package/dist/lib/file-operations/{search-content.js → search.js} +418 -20
  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.js +1 -2
  14. package/dist/lib/observability.js +1 -1
  15. package/dist/lib/{path-validation.d.ts → paths.d.ts} +11 -0
  16. package/dist/lib/{path-validation.js → paths.js} +145 -12
  17. package/dist/lib/utils.d.ts +15 -0
  18. package/dist/lib/utils.js +33 -0
  19. package/dist/resources/generated-instructions.js +6 -6
  20. package/dist/resources/tool-catalog.js +9 -9
  21. package/dist/resources/tool-info.js +3 -3
  22. package/dist/resources/workflows.js +10 -23
  23. package/dist/schemas.d.ts +11 -11
  24. package/dist/schemas.js +18 -31
  25. package/dist/server/bootstrap.d.ts +19 -1
  26. package/dist/server/bootstrap.js +130 -44
  27. package/dist/server/roots-manager.d.ts +5 -2
  28. package/dist/server/roots-manager.js +17 -5
  29. package/dist/tools/apply-patch.js +4 -3
  30. package/dist/tools/calculate-hash.js +4 -4
  31. package/dist/tools/create-directory.js +1 -1
  32. package/dist/tools/delete-file.js +3 -3
  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 +3 -4
  38. package/dist/tools/read.js +3 -3
  39. package/dist/tools/replace-in-files.js +8 -10
  40. package/dist/tools/roots.js +4 -6
  41. package/dist/tools/search-content.js +7 -9
  42. package/dist/tools/search-files.js +4 -6
  43. package/dist/tools/shared.d.ts +2 -1
  44. package/dist/tools/shared.js +2 -1
  45. package/dist/tools/stat-many.js +4 -3
  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 +4 -4
  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/glob-helpers.d.ts +0 -18
  56. package/dist/lib/file-operations/glob-helpers.js +0 -23
  57. package/dist/lib/file-operations/list-directory.d.ts +0 -14
  58. package/dist/lib/file-operations/list-directory.js +0 -252
  59. package/dist/lib/file-operations/read-multiple-files.d.ts +0 -25
  60. package/dist/lib/file-operations/read-multiple-files.js +0 -252
  61. package/dist/lib/file-operations/search-files.d.ts +0 -27
  62. package/dist/lib/file-operations/search-files.js +0 -216
  63. package/dist/lib/file-operations/search-matcher.d.ts +0 -10
  64. package/dist/lib/file-operations/search-matcher.js +0 -72
  65. package/dist/lib/file-operations/search-worker.d.ts +0 -2
  66. package/dist/lib/file-operations/search-worker.js +0 -131
  67. package/dist/lib/file-operations/tree.d.ts +0 -28
  68. package/dist/lib/file-operations/tree.js +0 -265
  69. package/dist/lib/option-utils.d.ts +0 -3
  70. package/dist/lib/option-utils.js +0 -15
  71. package/dist/lib/path-format.d.ts +0 -1
  72. package/dist/lib/path-format.js +0 -7
  73. package/dist/lib/path-policy.d.ts +0 -2
  74. package/dist/lib/path-policy.js +0 -100
  75. package/dist/lib/progress-reporting.d.ts +0 -11
  76. package/dist/lib/progress-reporting.js +0 -13
  77. package/dist/lib/type-guards.d.ts +0 -1
  78. package/dist/lib/type-guards.js +0 -3
  79. package/dist/server/capabilities.d.ts +0 -10
  80. package/dist/server/capabilities.js +0 -48
  81. package/dist/server/logging.d.ts +0 -7
  82. package/dist/server/logging.js +0 -41
  83. package/dist/server/types.d.ts +0 -4
  84. package/dist/server/types.js +0 -1
@@ -1,6 +1,24 @@
1
1
  import * as http from 'node:http';
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
- import type { ServerOptions } from './types.js';
3
+ import type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js';
4
+ export interface ServerOptions {
5
+ allowCwd?: boolean;
6
+ cliAllowedDirs?: string[];
7
+ }
8
+ interface CapabilityOptions {
9
+ enablePromptListChanged?: boolean;
10
+ enableTaskToolRequests?: boolean;
11
+ }
12
+ type ServerCapabilities = NonNullable<ConstructorParameters<typeof McpServer>[1]>['capabilities'];
13
+ type NonOptionalServerCapabilities = NonNullable<ServerCapabilities>;
14
+ export declare function buildServerCapabilities(options?: CapabilityOptions): NonOptionalServerCapabilities;
15
+ export declare function supportsTaskToolRequests(): boolean;
16
+ export interface LoggingState {
17
+ minimumLevel: LoggingLevel;
18
+ }
19
+ export declare function createLoggingState(minimumLevel?: LoggingLevel): LoggingState;
20
+ export declare function logToMcp(server: McpServer | undefined, level: LoggingLevel, data: string, minLevel?: LoggingLevel): void;
4
21
  export declare function createServer(options?: ServerOptions): Promise<McpServer>;
5
22
  export declare function startServer(server: McpServer): Promise<void>;
6
23
  export declare function startHttpServer(port: number, options: ServerOptions): Promise<http.Server>;
24
+ export {};
@@ -6,19 +6,105 @@ 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
- import { DEFAULT_LOG_LEVEL, parseEnvInt, REQUIRED_MCP_PROTOCOL_VERSION, } from '../lib/constants.js';
9
+ import { DEFAULT_LOG_LEVEL, parseEnvInt } from '../lib/constants.js';
11
10
  import { formatUnknownErrorMessage } from '../lib/errors.js';
11
+ import { withAllowedDirectoriesState } from '../lib/paths.js';
12
12
  import { createInMemoryResourceStore } from '../lib/resource-store.js';
13
+ import { isRecord } from '../lib/utils.js';
14
+ import { registerCompletions } from '../completions.js';
13
15
  import { pkgInfo } from '../pkg-info.js';
14
16
  import { registerGetHelpPrompt } from '../prompts.js';
15
17
  import { registerInstructionResource, registerMetricsResource, registerResultResources, registerToolCatalogResource, registerToolInfoResource, registerWorkflowGuideResource, } from '../resources.js';
16
18
  import { buildServerInstructions } from '../resources/generated-instructions.js';
17
19
  import { registerAllTools } from '../tools.js';
18
20
  import { withDefaultIcons } from '../tools/shared.js';
19
- import { buildServerCapabilities, supportsTaskToolRequests, } from './capabilities.js';
20
- import { createLoggingState } from './logging.js';
21
21
  import { RootsManager } from './roots-manager.js';
22
+ let cachedTaskToolSupport;
23
+ function detectTaskToolSupport() {
24
+ if (cachedTaskToolSupport !== undefined) {
25
+ return cachedTaskToolSupport;
26
+ }
27
+ try {
28
+ // Instantiate a minimal, unconnected probe server to duck-type check for
29
+ // task tool support. The probe has no transport or active connections, so
30
+ // close() only releases in-memory state; fire-and-forget is safe here.
31
+ const probe = new McpServer({
32
+ name: 'filesystem-mcp-capability-probe',
33
+ version: '0.0.0',
34
+ }, { capabilities: { tools: {} } });
35
+ cachedTaskToolSupport =
36
+ typeof probe.experimental.tasks.registerToolTask === 'function';
37
+ probe.close().catch(() => { });
38
+ }
39
+ catch {
40
+ cachedTaskToolSupport = false;
41
+ }
42
+ return cachedTaskToolSupport;
43
+ }
44
+ export function buildServerCapabilities(options = {}) {
45
+ const capabilities = {
46
+ logging: {},
47
+ resources: {},
48
+ tools: {},
49
+ prompts: options.enablePromptListChanged ? { listChanged: true } : {},
50
+ completions: {},
51
+ };
52
+ if (options.enableTaskToolRequests) {
53
+ // NOTE: enabling task tool requests requires the caller to configure
54
+ // an InMemoryTaskStore and InMemoryTaskMessageQueue on the McpServer.
55
+ // InMemoryTaskStore accumulates completed task records with no TTL eviction —
56
+ // suitable for short-lived stdio sessions. Long-running HTTP servers should
57
+ // replace it with a TTL-evicting store to avoid unbounded memory growth.
58
+ capabilities.tasks = {
59
+ list: {},
60
+ cancel: {},
61
+ requests: { tools: { call: {} } },
62
+ };
63
+ }
64
+ return capabilities;
65
+ }
66
+ export function supportsTaskToolRequests() {
67
+ return detectTaskToolSupport();
68
+ }
69
+ const MCP_LOGGER_NAME = 'filesystem-mcp';
70
+ const LOG_LEVEL_ORDER = {
71
+ debug: 0,
72
+ info: 1,
73
+ notice: 2,
74
+ warning: 3,
75
+ error: 4,
76
+ critical: 5,
77
+ alert: 6,
78
+ emergency: 7,
79
+ };
80
+ export function createLoggingState(minimumLevel = 'debug') {
81
+ return { minimumLevel };
82
+ }
83
+ function canSendMcpLogs(server) {
84
+ const capabilities = server.server.getClientCapabilities();
85
+ if (!isRecord(capabilities))
86
+ return false;
87
+ if (!('logging' in capabilities))
88
+ return false;
89
+ return !!capabilities['logging'];
90
+ }
91
+ export function logToMcp(server, level, data, minLevel = 'debug') {
92
+ if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[minLevel]) {
93
+ return;
94
+ }
95
+ if (!server || !canSendMcpLogs(server)) {
96
+ console.error(data);
97
+ return;
98
+ }
99
+ const params = {
100
+ level,
101
+ logger: MCP_LOGGER_NAME,
102
+ data,
103
+ };
104
+ void server.sendLoggingMessage(params).catch((error) => {
105
+ console.error(`Failed to send MCP log: ${level} | ${data}`, formatUnknownErrorMessage(error));
106
+ });
107
+ }
22
108
  const { version: SERVER_VERSION, description: SERVER_DESCRIPTION, homepage: SERVER_HOMEPAGE, } = pkgInfo;
23
109
  const rootsManagers = new WeakMap();
24
110
  function getRootsManager(server) {
@@ -164,7 +250,7 @@ async function createHttpSession(options, sessions) {
164
250
  const transport = new StreamableHTTPServerTransport({
165
251
  sessionIdGenerator: () => randomUUID(),
166
252
  onsessioninitialized: (sessionId) => {
167
- sessions.set(sessionId, { server: mcpServer, transport });
253
+ sessions.set(sessionId, { server: mcpServer, rootsManager, transport });
168
254
  rootsManager.logMissingDirectoriesIfNeeded(mcpServer);
169
255
  },
170
256
  });
@@ -179,7 +265,7 @@ async function createHttpSession(options, sessions) {
179
265
  });
180
266
  };
181
267
  await mcpServer.connect(transport);
182
- return { server: mcpServer, transport };
268
+ return { server: mcpServer, rootsManager, transport };
183
269
  }
184
270
  function sendJsonRpcError(res, status, code, message) {
185
271
  res.writeHead(status, { 'Content-Type': 'application/json' });
@@ -195,32 +281,33 @@ function isAllowedOrigin(origin) {
195
281
  return true; // Non-browser clients omit Origin.
196
282
  return LOCALHOST_ORIGIN_RE.test(origin);
197
283
  }
198
- function getProtocolVersionHeader(req) {
199
- const rawProtocolVersion = req.headers['mcp-protocol-version'];
200
- if (typeof rawProtocolVersion === 'string') {
201
- return rawProtocolVersion;
202
- }
203
- if (Array.isArray(rawProtocolVersion)) {
204
- return rawProtocolVersion.find((value) => value === REQUIRED_MCP_PROTOCOL_VERSION);
205
- }
206
- return undefined;
207
- }
208
- function ensureProtocolVersionHeader(req, res) {
209
- const protocolVersion = getProtocolVersionHeader(req);
210
- if (protocolVersion === REQUIRED_MCP_PROTOCOL_VERSION) {
211
- return true;
212
- }
213
- sendJsonRpcError(res, 400, -32000, 'Bad Request: MCP-Protocol-Version header missing or unsupported');
214
- return false;
215
- }
216
284
  function discardRequestBody(req) {
217
285
  req.on('error', () => {
218
286
  // Best effort drain to avoid corrupting keep-alive pipelines.
219
287
  });
220
288
  req.resume();
221
289
  }
290
+ async function handleSessionTransportRequest(session, req, res, body) {
291
+ await withAllowedDirectoriesState(session.rootsManager.getAllowedDirectoriesState(), () => session.transport.handleRequest(req, res, body));
292
+ }
293
+ function isLoopbackHttpHost(host) {
294
+ const normalized = host.trim().toLowerCase();
295
+ return (normalized === '127.0.0.1' ||
296
+ normalized === 'localhost' ||
297
+ normalized === '::1' ||
298
+ normalized === '[::1]');
299
+ }
300
+ function assertHttpBindingSecurity(host) {
301
+ if (isLoopbackHttpHost(host))
302
+ return;
303
+ if (process.env['FILESYSTEM_MCP_API_KEY'])
304
+ return;
305
+ throw new Error(`Refusing to bind HTTP server to non-loopback host '${host}' without FILESYSTEM_MCP_API_KEY.`);
306
+ }
222
307
  export async function startHttpServer(port, options) {
223
308
  const sessions = new Map();
309
+ const httpHost = process.env['FILESYSTEM_MCP_HTTP_HOST'] ?? '127.0.0.1';
310
+ assertHttpBindingSecurity(httpHost);
224
311
  async function handleMcpRequest(req, res) {
225
312
  const { method } = req;
226
313
  const MAX_SESSION_ID_LENGTH = 256;
@@ -242,9 +329,11 @@ export async function startHttpServer(port, options) {
242
329
  if (typeof authHeader === 'string' &&
243
330
  authHeader.startsWith(bearerPrefix)) {
244
331
  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);
332
+ if (userKey.length <= 4096) {
333
+ const expectedHash = createHash('sha256').update(apiKey).digest();
334
+ const actualHash = createHash('sha256').update(userKey).digest();
335
+ authorized = timingSafeEqual(expectedHash, actualHash);
336
+ }
248
337
  }
249
338
  if (!authorized) {
250
339
  res.writeHead(401, {
@@ -267,14 +356,10 @@ export async function startHttpServer(port, options) {
267
356
  discardRequestBody(req);
268
357
  return;
269
358
  }
270
- if (!ensureProtocolVersionHeader(req, res)) {
271
- discardRequestBody(req);
272
- return;
273
- }
274
359
  const body = await readRequestBody(req);
275
360
  const session = sessions.get(sessionId);
276
361
  if (session) {
277
- await session.transport.handleRequest(req, res, body);
362
+ await handleSessionTransportRequest(session, req, res, body);
278
363
  }
279
364
  else {
280
365
  sendJsonRpcError(res, 404, -32000, 'Session not found');
@@ -283,13 +368,13 @@ export async function startHttpServer(port, options) {
283
368
  }
284
369
  const body = await readRequestBody(req);
285
370
  if (isInitializeRequest(body)) {
286
- const maxSessions = parseInt(process.env['FILESYSTEM_MCP_MAX_HTTP_SESSIONS'] ?? '', 10) || 100;
371
+ const maxSessions = parseEnvInt('FILESYSTEM_MCP_MAX_HTTP_SESSIONS', 100, 1, 10_000);
287
372
  if (sessions.size >= maxSessions) {
288
373
  sendJsonRpcError(res, 503, -32000, 'Too many sessions');
289
374
  return;
290
375
  }
291
- const { transport } = await createHttpSession(options, sessions);
292
- await transport.handleRequest(req, res, body);
376
+ const session = await createHttpSession(options, sessions);
377
+ await handleSessionTransportRequest(session, req, res, body);
293
378
  return;
294
379
  }
295
380
  sendJsonRpcError(res, 400, -32000, 'Bad Request: No valid session ID provided');
@@ -304,20 +389,24 @@ export async function startHttpServer(port, options) {
304
389
  sendJsonRpcError(res, 404, -32000, 'Session not found');
305
390
  return;
306
391
  }
307
- if (!ensureProtocolVersionHeader(req, res)) {
308
- return;
309
- }
310
392
  const session = sessions.get(sessionId);
311
393
  if (session) {
312
- await session.transport.handleRequest(req, res);
394
+ await handleSessionTransportRequest(session, req, res);
313
395
  }
314
396
  else {
315
397
  sendJsonRpcError(res, 404, -32000, 'Session not found');
316
398
  }
317
399
  }
318
400
  else {
319
- res.writeHead(405, { Allow: 'GET, POST, DELETE' });
320
- res.end('Method Not Allowed');
401
+ res.writeHead(405, {
402
+ Allow: 'GET, POST, DELETE',
403
+ 'Content-Type': 'application/json',
404
+ });
405
+ res.end(JSON.stringify({
406
+ jsonrpc: '2.0',
407
+ error: { code: -32000, message: 'Method Not Allowed' },
408
+ id: null,
409
+ }));
321
410
  }
322
411
  }
323
412
  catch (error) {
@@ -345,9 +434,6 @@ export async function startHttpServer(port, options) {
345
434
  res.end('Not Found');
346
435
  }
347
436
  });
348
- // Default to localhost-only binding to prevent DNS-rebinding and unintended
349
- // external exposure. Override with FILESYSTEM_MCP_HTTP_HOST for remote setups.
350
- const httpHost = process.env['FILESYSTEM_MCP_HTTP_HOST'] ?? '127.0.0.1';
351
437
  return new Promise((resolve, reject) => {
352
438
  httpServer.once('error', reject);
353
439
  httpServer.listen(port, httpHost, () => {
@@ -1,9 +1,11 @@
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 AllowedDirectoriesState } from '../lib/paths.js';
3
+ import { type LoggingState } from './bootstrap.js';
4
+ import type { ServerOptions } from './bootstrap.js';
4
5
  export declare class RootsManager {
5
6
  private rootsUpdateTimeout;
6
7
  private rootDirectories;
8
+ private allowedDirectoriesState;
7
9
  private clientInitialized;
8
10
  private updatingRoots;
9
11
  private pendingRootsUpdate;
@@ -12,6 +14,7 @@ export declare class RootsManager {
12
14
  constructor(options: ServerOptions, loggingState: LoggingState);
13
15
  isInitialized(): boolean;
14
16
  destroy(): void;
17
+ getAllowedDirectoriesState(): AllowedDirectoriesState;
15
18
  logMissingDirectoriesIfNeeded(server: McpServer): void;
16
19
  registerHandlers(server: McpServer): void;
17
20
  recomputeAllowedDirectories(): Promise<void>;
@@ -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 { getValidRootDirectories, isPathWithinDirectories, normalizePath, resolveAllowedDirectoriesState, setAllowedDirectoriesStateResolved, } 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) {
@@ -83,6 +83,10 @@ async function filterRootsWithinBaseline(roots, baseline, signal) {
83
83
  export class RootsManager {
84
84
  rootsUpdateTimeout;
85
85
  rootDirectories = [];
86
+ allowedDirectoriesState = {
87
+ primary: [],
88
+ expanded: [],
89
+ };
86
90
  clientInitialized = false;
87
91
  // Set to true when an update is in progress, to prevent concurrent executions. If a change arrives while true, we queue a single retry after completion to ensure the last-known state is applied. This
88
92
  updatingRoots = false;
@@ -103,8 +107,14 @@ export class RootsManager {
103
107
  this.rootsUpdateTimeout = undefined;
104
108
  }
105
109
  }
110
+ getAllowedDirectoriesState() {
111
+ return {
112
+ primary: [...this.allowedDirectoriesState.primary],
113
+ expanded: [...this.allowedDirectoriesState.expanded],
114
+ };
115
+ }
106
116
  logMissingDirectoriesIfNeeded(server) {
107
- if (getAllowedDirectories().length === 0) {
117
+ if (this.allowedDirectoriesState.expanded.length === 0) {
108
118
  this.logMissingDirectories(server);
109
119
  }
110
120
  }
@@ -130,7 +140,9 @@ export class RootsManager {
130
140
  ? await filterRootsWithinBaseline(this.rootDirectories, baseline, signal)
131
141
  : this.rootDirectories;
132
142
  const combined = [...baseline, ...rootsToInclude];
133
- await setAllowedDirectoriesResolved(combined, signal);
143
+ const nextState = await resolveAllowedDirectoriesState(combined, signal);
144
+ this.allowedDirectoriesState = nextState;
145
+ setAllowedDirectoriesStateResolved(nextState);
134
146
  }
135
147
  finally {
136
148
  cleanup();
@@ -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 });
@@ -4,11 +4,11 @@ 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';
11
- import { reportPeriodicProgress } from '../lib/progress-reporting.js';
10
+ import { validateExistingPath } from '../lib/paths.js';
11
+ import { reportPeriodicProgress } from '../lib/utils.js';
12
12
  import { CalculateHashInputSchema, CalculateHashOutputSchema, } from '../schemas.js';
13
13
  import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
14
14
  import { registerToolTaskIfAvailable } from './task-support.js';
@@ -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';
@@ -2,7 +2,7 @@ import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import { ErrorCode, isNodeError, McpError } from '../lib/errors.js';
4
4
  import { withAbort } from '../lib/fs-helpers.js';
5
- import { isAllowedDirectoryRoot, 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,8 +14,8 @@ 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 undo — deletion is permanent.',
18
+ 'Non-empty directories require `recursive=true`.',
19
19
  ],
20
20
  };
21
21
  async function handleDeleteFile(args, 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
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,
@@ -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,28 +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';
11
- import { reportPeriodicProgress } from '../lib/progress-reporting.js';
10
+ import { validateExistingPath, validatePathForWrite } from '../lib/paths.js';
11
+ import { reportPeriodicProgress } from '../lib/utils.js';
12
12
  import { SearchAndReplaceInputSchema, SearchAndReplaceOutputSchema, } from '../schemas.js';
13
13
  import { buildToolErrorResponse, buildToolResponse, createToolProgressSession, DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS, executeToolWithDiagnostics, resolveFinalProgressCurrent, resolvePathOrRoot, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
14
14
  import { registerToolTaskIfAvailable } from './task-support.js';
15
15
  export const SEARCH_AND_REPLACE_TOOL = {
16
16
  name: 'search_and_replace',
17
17
  title: 'Search and Replace',
18
- description: 'Search and replace text across multiple files matching a glob pattern. ' +
19
- 'Replaces ALL occurrences in each file (unlike `edit` which replaces only the first). ' +
20
- 'Use `filePattern` to scope which files are touched. ' +
21
- 'Always run with `dryRun: true` first to verify matches before writing. ' +
22
- 'Returns a unified diff of changes in `dryRun` mode. ' +
23
- '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).',
24
22
  inputSchema: SearchAndReplaceInputSchema,
25
23
  outputSchema: SearchAndReplaceOutputSchema,
26
24
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
27
25
  taskSupport: 'optional',
28
26
  gotchas: [
29
- '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.',
30
28
  ],
31
29
  nuances: [
32
30
  'Changed-file sample and failure sample are capped/truncated in output.',