@j0hanz/filesystem-mcp 1.5.1 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -195,7 +195,7 @@ export async function parseArgs() {
195
195
  throw error;
196
196
  }
197
197
  const options = cli.opts();
198
- const allowCwd = options.allowCwd === true;
198
+ const allowCwd = Boolean(options.allowCwd);
199
199
  const port = parsePortOption(options.port);
200
200
  const positionals = getParsedAllowedDirs(cli);
201
201
  let allowedDirs;
@@ -1,3 +1,4 @@
1
+ export declare function parseTrueEnvFlag(value: string | undefined): boolean;
1
2
  export declare const DEFAULT_LOG_LEVEL: "debug" | "info" | "notice" | "warning" | "error" | "critical" | "alert" | "emergency";
2
3
  export declare const PARALLEL_CONCURRENCY: number;
3
4
  export declare const MAX_SEARCHABLE_FILE_SIZE: number;
@@ -1,6 +1,11 @@
1
1
  import { availableParallelism } from 'node:os';
2
2
  const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'y', 'on']);
3
3
  const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'n', 'off']);
4
+ export function parseTrueEnvFlag(value) {
5
+ if (value === undefined)
6
+ return false;
7
+ return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
8
+ }
4
9
  const KIB = 1024;
5
10
  const MIB = 1024 * KIB;
6
11
  function logInvalidEnvValue(envVar, value, expected, defaultValue) {
@@ -419,7 +419,7 @@ async function readRangeContent(handle, startLine, endLine, options) {
419
419
  if (hasEndLine && lineNumber === stopAt) {
420
420
  const peek = await iterator.next();
421
421
  hasMoreLines = !peek.done;
422
- reachedEof = peek.done === true;
422
+ reachedEof = Boolean(peek.done);
423
423
  stoppedEarly = true;
424
424
  break;
425
425
  }
@@ -2,21 +2,18 @@ import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { hash } from 'node:crypto';
3
3
  import { channel, tracingChannel } from 'node:diagnostics_channel';
4
4
  import { monitorEventLoopDelay, performance, PerformanceObserver, } from 'node:perf_hooks';
5
+ import { parseTrueEnvFlag } from './constants.js';
5
6
  import { isRecord } from './type-guards.js';
6
7
  // --- Configuration ---
7
8
  const ENV = process.env;
8
9
  let _cachedConfig;
9
10
  function readConfig() {
10
11
  return (_cachedConfig ??= {
11
- enabled: isTrue(ENV['FS_CONTEXT_DIAGNOSTICS']),
12
+ enabled: parseTrueEnvFlag(ENV['FS_CONTEXT_DIAGNOSTICS']),
12
13
  detail: parseDetail(ENV['FS_CONTEXT_DIAGNOSTICS_DETAIL']),
13
- logToolErrors: isTrue(ENV['FS_CONTEXT_TOOL_LOG_ERRORS']),
14
+ logToolErrors: parseTrueEnvFlag(ENV['FS_CONTEXT_TOOL_LOG_ERRORS']),
14
15
  });
15
16
  }
16
- function isTrue(val) {
17
- const norm = val?.trim().toLowerCase();
18
- return norm === '1' || norm === 'true' || norm === 'yes';
19
- }
20
17
  function parseDetail(val) {
21
18
  if (val === '2')
22
19
  return 2;
package/dist/prompts.js CHANGED
@@ -9,7 +9,14 @@ function filterInstructionsByTopic(instructions, topic) {
9
9
  return instructions;
10
10
  const sections = instructions.split(/\n(?=## )/u);
11
11
  const match = sections.find((sec) => sec.toLowerCase().startsWith(`## ${normalized}`));
12
- return match ?? instructions;
12
+ if (match !== undefined)
13
+ return match;
14
+ const available = sections
15
+ .filter((sec) => sec.startsWith('## '))
16
+ .map((sec) => sec.split('\n')[0]?.replace(/^##\s*/u, '') ?? '')
17
+ .filter(Boolean)
18
+ .join(', ');
19
+ return `Section '${topic}' not found. Available sections: ${available}\n\n${instructions}`;
13
20
  }
14
21
  export function registerGetHelpPrompt(server, instructions, iconInfo) {
15
22
  const baseConfig = withDefaultIcons({ title: HELP_PROMPT_TITLE, description: HELP_PROMPT_DESCRIPTION }, iconInfo);
@@ -1,32 +1,28 @@
1
1
  import { buildToolCatalogDetailsOnly } from './tool-catalog.js';
2
2
  import { buildCoreContextPack, getSharedConstraints, getToolContracts, } from './tool-info.js';
3
3
  import { buildWorkflowGuide } from './workflows.js';
4
- const INSTRUCTIONS_HEADER = `# FILESYSTEM-MCP INSTRUCTIONS
4
+ const INSTRUCTIONS_HEADER = `# FILESYSTEM-MCP
5
5
 
6
- > Resource: \`internal://instructions\` | Prompt: \`get-help\`
6
+ Operate ONLY within allowed roots. Always discover before acting — never guess paths.
7
7
 
8
- ## CORE CAPABILITY
8
+ ## TOOLS
9
9
 
10
- - **Domain:** Safe local filesystem operations (read/write/diff/patch) within allowed roots.
11
- - **Tools:**
12
- - READ: \`roots\`, \`ls\`, \`find\`, \`tree\`, \`read\`, \`read_many\`, \`stat\`, \`stat_many\`, \`grep\`, \`calculate_hash\`, \`diff_files\`.
13
- - WRITE: \`mkdir\`, \`write\`, \`edit\`, \`mv\`, \`rm\`, \`apply_patch\`, \`search_and_replace\`.
10
+ | Category | Tools |
11
+ |----------|-------|
12
+ | Navigate | \`roots\`, \`ls\`, \`tree\`, \`find\` |
13
+ | Inspect | \`stat\`, \`stat_many\`, \`grep\`, \`calculate_hash\` |
14
+ | Read | \`read\`, \`read_many\`, \`diff_files\` |
15
+ | Write | \`mkdir\`, \`write\`, \`edit\`, \`mv\`, \`rm\`, \`apply_patch\`, \`search_and_replace\` |
14
16
 
15
17
  ## RESOURCES
16
18
 
17
- - \`filesystem-mcp://result/{id}\`: Ephemeral cached output.
18
- - \`filesystem-mcp://metrics\`: Live tool stats.
19
- - **Tip:** If response has \`resourceUri\`, call \`resources/read\` to fetch full content.
19
+ - \`filesystem-mcp://result/{id}\`: Large output is cached here. **If a response includes \`resourceUri\`, call \`resources/read\` immediately — results expire on process restart.**
20
+ - \`filesystem-mcp://metrics\`: Live per-tool call/error stats.
20
21
 
21
- ## PROGRESS & TASKS
22
+ ## TASK PROTOCOL
22
23
 
23
- - Support \`_meta.progressToken\` for updates.
24
- - Task tools: \`find\`, \`tree\`, \`read\`, \`read_many\`, \`stat_many\`, \`grep\`, \`mkdir\`, \`write\`, \`mv\`, \`rm\`, \`calculate_hash\`, \`apply_patch\`, \`search_and_replace\`.
25
- - Flow: \`tools/call\` (task) → \`tasks/get\` → \`tasks/result\`.
26
-
27
- ## GOLDEN PATH WORKFLOWS
28
-
29
- See "Workflow Reference" below for detailed execution sequences.
24
+ Long-running tools support async execution: provide \`_meta.progressToken\` in \`tools/call\`, poll \`tasks/get\`, then call \`tasks/result\`.
25
+ Task-capable: \`find\`, \`tree\`, \`read\`, \`read_many\`, \`stat_many\`, \`grep\`, \`mkdir\`, \`write\`, \`mv\`, \`rm\`, \`calculate_hash\`, \`apply_patch\`, \`search_and_replace\`.
30
26
 
31
27
  `;
32
28
  const INSTRUCTIONS_FOOTER = `
@@ -45,22 +41,11 @@ ${getSharedConstraints()
45
41
  `;
46
42
  function formatToolSection(tool) {
47
43
  const parts = [`${tool.name}: ${tool.description}`];
48
- if (tool.annotations) {
49
- const attrs = [];
50
- if (tool.annotations.destructiveHint)
51
- attrs.push('[Destructive]');
52
- if (tool.annotations.idempotentHint)
53
- attrs.push('[Idempotent]');
54
- if (tool.annotations.readOnlyHint)
55
- attrs.push('[Read-Only]');
56
- if (attrs.length > 0)
57
- parts.push(attrs.join(' '));
58
- }
59
44
  if (tool.nuances && tool.nuances.length > 0) {
60
- parts.push(...tool.nuances.map((n) => `! ${n}`));
45
+ parts.push(...tool.nuances.map((n) => ${n}`));
61
46
  }
62
47
  if (tool.gotchas && tool.gotchas.length > 0) {
63
- parts.push(...tool.gotchas.map((g) => `! ${g}`));
48
+ parts.push(...tool.gotchas.map((g) => `⚠ ${g}`));
64
49
  }
65
50
  return parts.join('\n');
66
51
  }
@@ -1,19 +1,25 @@
1
1
  import { buildCoreContextPack } from './tool-info.js';
2
- const CATALOG_GUIDE = `## Tool Catalog Details
2
+ const CATALOG_GUIDE = `## Tool Selection Guide
3
3
 
4
4
  ## Cross-Tool Data Flow
5
5
 
6
6
  \`\`\`
7
- find -> output_paths -> grep.paths
8
- diff_files -> output_patch -> apply_patch.patch
7
+ find (results[].path) -> grep.paths
8
+ diff_files (patch text) -> apply_patch.patch
9
9
  \`\`\`
10
10
 
11
- ## Search Strategy Strategy
11
+ ## Search Strategy
12
12
 
13
13
  - Use \`find\` for glob-based file discovery.
14
14
  - Use \`grep\` for content-based searches.
15
15
  - Use \`search_and_replace\` ONLY for bulk replacements, not for discovery.
16
16
 
17
+ ## Write Strategy
18
+
19
+ - Use \`edit\` for precise, single-occurrence string replacements in existing files.
20
+ - Use \`write\` to create new files or completely overwrite existing content.
21
+ - Use \`search_and_replace\` for bulk regex replacements across multiple files.
22
+
17
23
  ## Patch Management
18
24
 
19
25
  - Always generate a patch with \`diff_files\` first.
@@ -21,7 +27,6 @@ diff_files -> output_patch -> apply_patch.patch
21
27
  - \`apply_patch\` works on unified diff format.
22
28
  `;
23
29
  export function buildToolCatalog() {
24
- // Return combined view for standalone resource usage
25
30
  return `${buildCoreContextPack()}\n\n${CATALOG_GUIDE}`;
26
31
  }
27
32
  export function buildToolCatalogDetailsOnly() {
@@ -40,6 +40,6 @@ export function getSharedConstraints() {
40
40
  'Allowed roots only (negotiated via CLI).',
41
41
  'Sensitive files denylisted by default.',
42
42
  `Max file size (${Math.floor(MAX_TEXT_FILE_SIZE / 1024 / 1024)}MB) & search results (${MAX_SEARCH_RESULTS} files, ${DEFAULT_SEARCH_CONTENT_RESULTS} lines) enforced.`,
43
- 'Externalized results are ephemeral (in-memory).',
43
+ 'If a response includes `resourceUri`, call `resources/read` immediately — results expire on process restart.',
44
44
  ];
45
45
  }
@@ -1,8 +1,8 @@
1
- import { getSharedConstraints } from './tool-info.js';
2
1
  export function buildWorkflowGuide() {
3
2
  return `## Workflow Reference
4
3
 
5
4
  ### A: EXPLORE
5
+ Use when: navigating an unfamiliar directory or reading file content.
6
6
  1. \`roots\` (List allowed paths).
7
7
  2. \`ls\` (files) | \`tree\` (structure).
8
8
  3. \`stat\` | \`stat_many\` (size/type check).
@@ -10,12 +10,14 @@ export function buildWorkflowGuide() {
10
10
  > **Strict:** Never guess paths. Resolve first.
11
11
 
12
12
  ### B: SEARCH
13
+ Use when: locating files by name pattern or by content match.
13
14
  1. \`find\` (glob candidates).
14
15
  2. \`grep\` (content search).
15
16
  3. \`read\` (verify context).
16
- > **Tip:** Content search requires \`grep\`, not \`find\`.
17
+ > **Strict:** Use \`grep\` for content search, not \`find\`.
17
18
 
18
19
  ### C: EDIT
20
+ Use when: modifying existing files or reorganizing the filesystem.
19
21
  1. \`edit\` (precise string match).
20
22
  2. \`search_and_replace\` (bulk regex/glob).
21
23
  3. \`mv\` | \`rm\` (file layout).
@@ -23,14 +25,10 @@ export function buildWorkflowGuide() {
23
25
  > **Strict:** Confirm destructive ops (\`write\`, \`mv\`, \`rm\`, bulk replace).
24
26
 
25
27
  ### D: PATCH
28
+ Use when: applying structured diffs produced by \`diff_files\`.
26
29
  1. \`diff_files\` (generate).
27
30
  2. \`apply_patch\` (dryRun: true).
28
31
  3. \`apply_patch\` (dryRun: false).
29
- > **Tip:** Use \`diff_files\` output directly.
30
-
31
- ## Shared Constraints
32
- ${getSharedConstraints()
33
- .map((c) => `- ${c}`)
34
- .join('\n')}
32
+ > **Tip:** Pass \`diff_files\` output directly into \`apply_patch\`.
35
33
  `;
36
34
  }
package/dist/resources.js CHANGED
@@ -47,7 +47,7 @@ export function registerToolCatalogResource(server, iconInfo) {
47
47
  mimeType: 'text/markdown',
48
48
  annotations: {
49
49
  audience: ['assistant'],
50
- priority: 0.6,
50
+ priority: 0.7,
51
51
  },
52
52
  }, iconInfo), (uri) => ({
53
53
  contents: [
@@ -66,7 +66,7 @@ export function registerWorkflowGuideResource(server, iconInfo) {
66
66
  mimeType: 'text/markdown',
67
67
  annotations: {
68
68
  audience: ['assistant'],
69
- priority: 0.7,
69
+ priority: 0.6,
70
70
  },
71
71
  }, iconInfo), (uri) => ({
72
72
  contents: [
package/dist/schemas.js CHANGED
@@ -26,8 +26,12 @@ const RequiredPathSchema = PathSchemaBase.min(1, 'Path required');
26
26
  const FileTypeSchema = z.enum(['file', 'directory', 'symlink', 'other']);
27
27
  const ListDirectorySortSchema = z.enum(['name', 'size', 'modified', 'type']);
28
28
  const SearchFilesSortSchema = z.enum(['name', 'size', 'modified', 'path']);
29
- const SearchStopReasonSchema = z.enum(['maxResults', 'maxFiles', 'timeout']);
30
- const ListDirectoryStopReasonSchema = z.enum(['maxEntries', 'aborted']);
29
+ const SearchStopReasonSchema = z
30
+ .enum(['maxResults', 'maxFiles', 'timeout'])
31
+ .describe('maxResults: result limit hit; maxFiles: file count limit hit; timeout: time limit exceeded');
32
+ const ListDirectoryStopReasonSchema = z
33
+ .enum(['maxEntries', 'aborted'])
34
+ .describe('maxEntries: entry limit hit; aborted: operation was cancelled');
31
35
  const TreeEntrySchema = z.lazy(() => z.strictObject({
32
36
  name: z.string().describe('Name'),
33
37
  type: FileTypeSchema.describe('Type'),
@@ -28,9 +28,6 @@ function getRootsManager(server) {
28
28
  }
29
29
  return manager;
30
30
  }
31
- function loadServerInstructions() {
32
- return buildServerInstructions();
33
- }
34
31
  async function getLocalIconInfo() {
35
32
  const name = 'logo.svg';
36
33
  const mime = 'image/svg+xml';
@@ -52,7 +49,7 @@ async function getLocalIconInfo() {
52
49
  }
53
50
  export async function createServer(options = {}) {
54
51
  const resourceStore = createInMemoryResourceStore();
55
- const serverInstructions = loadServerInstructions();
52
+ const serverInstructions = buildServerInstructions();
56
53
  const localIcon = await getLocalIconInfo();
57
54
  const taskToolSupport = supportsTaskToolRequests();
58
55
  const serverConfig = {
@@ -68,7 +65,7 @@ export async function createServer(options = {}) {
68
65
  if (serverInstructions) {
69
66
  serverConfig.instructions =
70
67
  'filesystem-mcp: Secure local filesystem MCP server. ' +
71
- 'Essential sequence: roots → ls/tree/find → read/grep. ' +
68
+ 'Always begin with: roots → ls/find → stat → read. Never guess paths. ' +
72
69
  'Full reference: read the internal://instructions resource or invoke the get-help prompt.';
73
70
  }
74
71
  const server = new McpServer(withDefaultIcons({
@@ -113,13 +110,37 @@ export async function startServer(server) {
113
110
  };
114
111
  rootsManager.logMissingDirectoriesIfNeeded(server);
115
112
  }
113
+ const MAX_REQUEST_BODY_BYTES = parseInt(process.env['FS_CONTEXT_MAX_REQUEST_BYTES'] ?? '', 10) ||
114
+ 4 * 1024 * 1024; // 4 MB default
115
+ class RequestBodyError extends Error {
116
+ statusCode;
117
+ constructor(message, statusCode) {
118
+ super(message);
119
+ this.statusCode = statusCode;
120
+ this.name = 'RequestBodyError';
121
+ }
122
+ }
116
123
  async function readRequestBody(req) {
117
124
  return new Promise((resolve, reject) => {
118
125
  const chunks = [];
126
+ let totalBytes = 0;
127
+ let tooBig = false;
119
128
  req.on('data', (chunk) => {
129
+ totalBytes += chunk.length;
130
+ if (totalBytes > MAX_REQUEST_BODY_BYTES) {
131
+ if (!tooBig) {
132
+ tooBig = true;
133
+ chunks.length = 0; // free accumulated memory
134
+ req.pause(); // stop emitting data events; TCP window fills naturally
135
+ reject(new RequestBodyError('Request body too large', 413));
136
+ }
137
+ return;
138
+ }
120
139
  chunks.push(chunk);
121
140
  });
122
141
  req.on('end', () => {
142
+ if (tooBig)
143
+ return; // already rejected in 'data' handler
123
144
  const raw = Buffer.concat(chunks).toString('utf-8');
124
145
  if (!raw) {
125
146
  resolve(undefined);
@@ -129,7 +150,7 @@ async function readRequestBody(req) {
129
150
  resolve(JSON.parse(raw));
130
151
  }
131
152
  catch {
132
- resolve(undefined);
153
+ reject(new RequestBodyError('Invalid JSON in request body', 400));
133
154
  }
134
155
  });
135
156
  req.on('error', reject);
@@ -171,11 +192,22 @@ function sendJsonRpcError(res, status, code, message) {
171
192
  id: null,
172
193
  }));
173
194
  }
195
+ const LOCALHOST_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/u;
196
+ function isAllowedOrigin(origin) {
197
+ if (origin === undefined)
198
+ return true; // Non-browser clients omit Origin.
199
+ return LOCALHOST_ORIGIN_RE.test(origin);
200
+ }
174
201
  export async function startHttpServer(port, options) {
175
202
  const sessions = new Map();
176
203
  async function handleMcpRequest(req, res) {
177
204
  const { method } = req;
178
205
  const sessionId = req.headers['mcp-session-id'];
206
+ const { origin } = req.headers;
207
+ if (!isAllowedOrigin(origin)) {
208
+ sendJsonRpcError(res, 403, -32000, 'Forbidden: disallowed origin');
209
+ return;
210
+ }
179
211
  const apiKey = process.env['FILESYSTEM_MCP_API_KEY'];
180
212
  if (apiKey) {
181
213
  const authHeader = req.headers['authorization'];
@@ -237,6 +269,12 @@ export async function startHttpServer(port, options) {
237
269
  }
238
270
  }
239
271
  catch (error) {
272
+ if (error instanceof RequestBodyError && !res.headersSent) {
273
+ const rpcCode = error.statusCode === 413 ? -32600 : -32700;
274
+ res.setHeader('Connection', 'close');
275
+ sendJsonRpcError(res, error.statusCode, rpcCode, error.message);
276
+ return;
277
+ }
240
278
  console.error('[HTTP] Error handling request:', formatUnknownErrorMessage(error));
241
279
  if (!res.headersSent) {
242
280
  sendJsonRpcError(res, 500, -32603, 'Internal Server Error');
@@ -255,10 +293,13 @@ export async function startHttpServer(port, options) {
255
293
  res.end('Not Found');
256
294
  }
257
295
  });
296
+ // Default to localhost-only binding to prevent DNS-rebinding and unintended
297
+ // external exposure. Override with FILESYSTEM_MCP_HTTP_HOST for remote setups.
298
+ const httpHost = process.env['FILESYSTEM_MCP_HTTP_HOST'] ?? '127.0.0.1';
258
299
  return new Promise((resolve, reject) => {
259
300
  httpServer.once('error', reject);
260
- httpServer.listen(port, () => {
261
- console.error(`MCP HTTP server listening on port ${port}`);
301
+ httpServer.listen(port, httpHost, () => {
302
+ console.error(`MCP HTTP server listening on ${httpHost}:${port}`);
262
303
  resolve(httpServer);
263
304
  });
264
305
  });
@@ -20,7 +20,7 @@ function canSendMcpLogs(server) {
20
20
  return false;
21
21
  if (!('logging' in capabilities))
22
22
  return false;
23
- return capabilities.logging !== null;
23
+ return !!capabilities['logging'];
24
24
  }
25
25
  export function logToMcp(server, level, data, minLevel = 'debug') {
26
26
  if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[minLevel]) {
@@ -117,7 +117,7 @@ export class RootsManager {
117
117
  }
118
118
  async recomputeAllowedDirectories() {
119
119
  const cliAllowedDirs = normalizeCLIDirectories(this.options.cliAllowedDirs ?? []);
120
- const allowCwd = this.options.allowCwd === true;
120
+ const allowCwd = Boolean(this.options.allowCwd);
121
121
  const allowCwdDirs = allowCwd ? [normalizePath(process.cwd())] : [];
122
122
  const baseline = [...cliAllowedDirs, ...allowCwdDirs];
123
123
  const { signal, cleanup } = createTimedAbortSignal(undefined, ROOTS_TIMEOUT_MS);
@@ -9,10 +9,11 @@ import { registerToolTaskIfAvailable } from './task-support.js';
9
9
  export const CREATE_DIRECTORY_TOOL = {
10
10
  name: 'mkdir',
11
11
  title: 'Create Directory',
12
- description: 'Create a new directory at the specified path (recursive)',
12
+ description: 'Create a new directory at the specified path (recursive).',
13
13
  inputSchema: CreateDirectoryInputSchema,
14
14
  outputSchema: CreateDirectoryOutputSchema,
15
15
  annotations: IDEMPOTENT_WRITE_TOOL_ANNOTATIONS,
16
+ nuances: ['Succeeds silently if the directory already exists (idempotent).'],
16
17
  };
17
18
  async function handleCreateDirectory(args, signal) {
18
19
  const validPath = await validatePathForWrite(args.path, signal);
@@ -9,11 +9,12 @@ import { registerToolTaskIfAvailable } from './task-support.js';
9
9
  export const DELETE_FILE_TOOL = {
10
10
  name: 'rm',
11
11
  title: 'Delete File',
12
- description: 'Delete a file or directory.',
12
+ description: 'Permanently delete a file or directory. This action is irreversible.',
13
13
  inputSchema: DeleteFileInputSchema,
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.',
17
18
  'Non-empty directory delete requires `recursive=true`; else returns actionable input error.',
18
19
  ],
19
20
  };
@@ -14,6 +14,9 @@ export const MOVE_FILE_TOOL = {
14
14
  outputSchema: MoveFileOutputSchema,
15
15
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
16
16
  nuances: ['Cross-device moves fall back to copy+delete.'],
17
+ gotchas: [
18
+ 'On POSIX, an existing destination is silently overwritten; on Windows, rename fails with EEXIST if destination exists.',
19
+ ],
17
20
  };
18
21
  async function handleMoveFile(args, signal) {
19
22
  const validSource = await validateExistingPath(args.source, signal);
@@ -24,7 +24,7 @@ export const SEARCH_CONTENT_TOOL = {
24
24
  'Skips binary and oversized files.',
25
25
  ],
26
26
  gotchas: [
27
- 'Inline match rows are capped (first 50); full structured results are externalized via `resourceUri`.',
27
+ 'Skips binary and oversized files silently check file type with `stat` if no matches appear.',
28
28
  ],
29
29
  taskSupport: 'required',
30
30
  };
@@ -1,5 +1,6 @@
1
1
  import { channel } from 'node:diagnostics_channel';
2
2
  import { z } from 'zod';
3
+ import { parseTrueEnvFlag } from '../lib/constants.js';
3
4
  import { createDetailedError, ErrorCode, formatDetailedError, getSuggestion, McpError, } from '../lib/errors.js';
4
5
  import { createTimedAbortSignal } from '../lib/fs-helpers.js';
5
6
  import { withToolDiagnostics } from '../lib/observability.js';
@@ -8,7 +9,6 @@ export {} from './contract.js';
8
9
  const MAX_INLINE_CONTENT_CHARS = parseInt(process.env['FS_CONTEXT_MAX_INLINE_CHARS'] ?? '', 10) || 20_000;
9
10
  const MAX_INLINE_PREVIEW_CHARS = 4_000;
10
11
  const PROGRESS_RATE_LIMIT_MS = 50;
11
- const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes']);
12
12
  const CONTEXT_DIAGNOSTICS_CHANNEL = channel('filesystem-mcp:context');
13
13
  function publishContextDiagnostics(event) {
14
14
  if (!CONTEXT_DIAGNOSTICS_CHANNEL.hasSubscribers)
@@ -33,10 +33,7 @@ export const IDEMPOTENT_WRITE_TOOL_ANNOTATIONS = {
33
33
  openWorldHint: false,
34
34
  };
35
35
  export function shouldStripStructuredOutput() {
36
- const value = process.env['FS_CONTEXT_STRIP_STRUCTURED'];
37
- if (value === undefined)
38
- return false;
39
- return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
36
+ return parseTrueEnvFlag(process.env['FS_CONTEXT_STRIP_STRUCTURED']);
40
37
  }
41
38
  export function maybeStripStructuredContentFromResult(result) {
42
39
  if (!shouldStripStructuredOutput())
@@ -9,7 +9,7 @@ import { registerToolTaskIfAvailable } from './task-support.js';
9
9
  export const GET_MULTIPLE_FILE_INFO_TOOL = {
10
10
  name: 'stat_many',
11
11
  title: 'Get Multiple File Info',
12
- description: 'Get metadata for multiple files or directories in one request.',
12
+ description: 'Get metadata (including tokenEstimate) for multiple files or directories in one request. Use tokenEstimate (size÷4) to pre-screen token cost before reading.',
13
13
  inputSchema: GetMultipleFileInfoInputSchema,
14
14
  outputSchema: GetMultipleFileInfoOutputSchema,
15
15
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
@@ -8,7 +8,7 @@ import { buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, execut
8
8
  export const GET_FILE_INFO_TOOL = {
9
9
  name: 'stat',
10
10
  title: 'Get File Info',
11
- description: 'Get metadata (size, modified time, permissions, mime type) for a file or directory.',
11
+ description: 'Get metadata (size, modified time, permissions, mime type, tokenEstimate) for a file or directory. Use tokenEstimate (size÷4) to pre-screen token cost before reading.',
12
12
  inputSchema: GetFileInfoInputSchema,
13
13
  outputSchema: GetFileInfoOutputSchema,
14
14
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
@@ -9,15 +9,12 @@ import { registerToolTaskIfAvailable } from './task-support.js';
9
9
  export const WRITE_FILE_TOOL = {
10
10
  name: 'write',
11
11
  title: 'Write File',
12
- description: 'Write content to a file. Creates the file if it does not exist.',
12
+ description: 'Write content to a file, OVERWRITING ALL existing content. Creates the file and parent directories if needed.',
13
13
  inputSchema: WriteFileInputSchema,
14
14
  outputSchema: WriteFileOutputSchema,
15
15
  annotations: DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS,
16
- nuances: [
17
- 'Creates parent directories automatically; overwrites existing content.',
18
- ],
19
16
  gotchas: [
20
- 'Creates parent directories automatically; overwrites existing content.',
17
+ '`write` replaces ALL existing content use `edit` for partial updates.',
21
18
  ],
22
19
  };
23
20
  async function handleWriteFile(args, signal) {
package/dist/tools.js CHANGED
@@ -18,48 +18,38 @@ import { GET_FILE_INFO_TOOL, registerGetFileInfoTool } from './tools/stat.js';
18
18
  import { registerTreeTool, TREE_TOOL } from './tools/tree.js';
19
19
  import { registerWriteFileTool, WRITE_FILE_TOOL } from './tools/write-file.js';
20
20
  export { buildToolErrorResponse, buildToolResponse } from './tools/shared.js';
21
- export const ALL_TOOLS = [
22
- LIST_ALLOWED_DIRECTORIES_TOOL,
23
- LIST_DIRECTORY_TOOL,
24
- SEARCH_FILES_TOOL,
25
- TREE_TOOL,
26
- READ_FILE_TOOL,
27
- READ_MULTIPLE_FILES_TOOL,
28
- GET_FILE_INFO_TOOL,
29
- GET_MULTIPLE_FILE_INFO_TOOL,
30
- SEARCH_CONTENT_TOOL,
31
- CREATE_DIRECTORY_TOOL,
32
- WRITE_FILE_TOOL,
33
- EDIT_FILE_TOOL,
34
- MOVE_FILE_TOOL,
35
- DELETE_FILE_TOOL,
36
- CALCULATE_HASH_TOOL,
37
- DIFF_FILES_TOOL,
38
- APPLY_PATCH_TOOL,
39
- SEARCH_AND_REPLACE_TOOL,
40
- ];
41
- const TOOL_REGISTRARS = [
42
- registerListAllowedDirectoriesTool,
43
- registerListDirectoryTool,
44
- registerSearchFilesTool,
45
- registerTreeTool,
46
- registerReadFileTool,
47
- registerReadMultipleFilesTool,
48
- registerGetFileInfoTool,
49
- registerGetMultipleFileInfoTool,
50
- registerSearchContentTool,
51
- registerCreateDirectoryTool,
52
- registerWriteFileTool,
53
- registerEditFileTool,
54
- registerMoveFileTool,
55
- registerDeleteFileTool,
56
- registerCalculateHashTool,
57
- registerDiffFilesTool,
58
- registerApplyPatchTool,
59
- registerSearchAndReplaceTool,
21
+ const TOOL_ENTRIES = [
22
+ {
23
+ contract: LIST_ALLOWED_DIRECTORIES_TOOL,
24
+ register: registerListAllowedDirectoriesTool,
25
+ },
26
+ { contract: LIST_DIRECTORY_TOOL, register: registerListDirectoryTool },
27
+ { contract: SEARCH_FILES_TOOL, register: registerSearchFilesTool },
28
+ { contract: TREE_TOOL, register: registerTreeTool },
29
+ { contract: READ_FILE_TOOL, register: registerReadFileTool },
30
+ {
31
+ contract: READ_MULTIPLE_FILES_TOOL,
32
+ register: registerReadMultipleFilesTool,
33
+ },
34
+ { contract: GET_FILE_INFO_TOOL, register: registerGetFileInfoTool },
35
+ {
36
+ contract: GET_MULTIPLE_FILE_INFO_TOOL,
37
+ register: registerGetMultipleFileInfoTool,
38
+ },
39
+ { contract: SEARCH_CONTENT_TOOL, register: registerSearchContentTool },
40
+ { contract: CREATE_DIRECTORY_TOOL, register: registerCreateDirectoryTool },
41
+ { contract: WRITE_FILE_TOOL, register: registerWriteFileTool },
42
+ { contract: EDIT_FILE_TOOL, register: registerEditFileTool },
43
+ { contract: MOVE_FILE_TOOL, register: registerMoveFileTool },
44
+ { contract: DELETE_FILE_TOOL, register: registerDeleteFileTool },
45
+ { contract: CALCULATE_HASH_TOOL, register: registerCalculateHashTool },
46
+ { contract: DIFF_FILES_TOOL, register: registerDiffFilesTool },
47
+ { contract: APPLY_PATCH_TOOL, register: registerApplyPatchTool },
48
+ { contract: SEARCH_AND_REPLACE_TOOL, register: registerSearchAndReplaceTool },
60
49
  ];
50
+ export const ALL_TOOLS = TOOL_ENTRIES.map((e) => e.contract);
61
51
  export function registerAllTools(server, options = {}) {
62
- for (const registerTool of TOOL_REGISTRARS) {
63
- registerTool(server, options);
52
+ for (const { register } of TOOL_ENTRIES) {
53
+ register(server, options);
64
54
  }
65
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/filesystem-mcp",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "mcpName": "io.github.j0hanz/filesystem-mcp",
5
5
  "description": "MCP Server that enables LLMs to interact with the local filesystem.",
6
6
  "type": "module",