feat(filesystem): ignore dot directories by default to reduce token usage

- Add environment variable MCP_FILESYSTEM_INCLUDE_HIDDEN (default: false)
- Filter dot-prefixed files/directories in list_directory, directory_tree, search_files
- Reduces token usage from large directories like .git, .terraform, etc.
- Enhances security by avoiding exposure of potentially sensitive hidden files
- Includes comprehensive tests for the new filtering functionality
- Update documentation to explain the new behavior and environment variable

Fixes #2219

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Ola Hungerford <olaservo@users.noreply.github.com>
This commit is contained in:
claude[bot]
2025-08-24 03:01:32 +00:00
parent 338d8af7a6
commit 81d6553ae0
4 changed files with 139 additions and 20 deletions

View File

@@ -15,6 +15,20 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio
The server uses a flexible directory access control system. Directories can be specified via command-line arguments or dynamically via [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots).
### Hidden Files and Directories
By default, the filesystem server **ignores dot-prefixed files and directories** (like `.git`, `.env`, `.terraform`, etc.) to:
- Reduce token usage (especially from large directories like `.git`)
- Enhance security by avoiding exposure of potentially sensitive hidden files
- Follow the convention that dot-prefixed items are typically "hidden" for good reason
To include hidden files and directories, set the environment variable:
```bash
export MCP_FILESYSTEM_INCLUDE_HIDDEN=true
```
This affects the following operations: `list_directory`, `list_directory_with_sizes`, `directory_tree`, and `search_files`.
### Method 1: Command-line Arguments
Specify Allowed directories when starting the server:
```bash

View File

@@ -18,6 +18,9 @@ import {
searchFilesWithValidation,
// File editing functions
applyFileEdits,
// Hidden files filtering functions
shouldIncludeHidden,
shouldFilterHiddenEntry,
tailFile,
headFile
} from '../lib.js';
@@ -699,3 +702,84 @@ describe('Lib Functions', () => {
});
});
});
describe('Hidden Files Filtering', () => {
describe('shouldIncludeHidden', () => {
const originalEnv = process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN;
afterEach(() => {
// Restore original environment variable
if (originalEnv === undefined) {
delete process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN;
} else {
process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN = originalEnv;
}
});
it('should return false by default (when env var not set)', () => {
delete process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN;
expect(shouldIncludeHidden()).toBe(false);
});
it('should return false when env var is set to "false"', () => {
process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN = 'false';
expect(shouldIncludeHidden()).toBe(false);
});
it('should return true when env var is set to "true"', () => {
process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN = 'true';
expect(shouldIncludeHidden()).toBe(true);
});
it('should return false for any other value', () => {
process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN = 'yes';
expect(shouldIncludeHidden()).toBe(false);
});
});
describe('shouldFilterHiddenEntry', () => {
const originalEnv = process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN;
afterEach(() => {
// Restore original environment variable
if (originalEnv === undefined) {
delete process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN;
} else {
process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN = originalEnv;
}
});
it('should filter dot-prefixed names by default', () => {
delete process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN;
expect(shouldFilterHiddenEntry('.git')).toBe(true);
expect(shouldFilterHiddenEntry('.env')).toBe(true);
expect(shouldFilterHiddenEntry('.terraform')).toBe(true);
expect(shouldFilterHiddenEntry('.vscode')).toBe(true);
});
it('should not filter regular names by default', () => {
delete process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN;
expect(shouldFilterHiddenEntry('README.md')).toBe(false);
expect(shouldFilterHiddenEntry('package.json')).toBe(false);
expect(shouldFilterHiddenEntry('src')).toBe(false);
expect(shouldFilterHiddenEntry('test.txt')).toBe(false);
});
it('should not filter any names when hidden files are included', () => {
process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN = 'true';
expect(shouldFilterHiddenEntry('.git')).toBe(false);
expect(shouldFilterHiddenEntry('.env')).toBe(false);
expect(shouldFilterHiddenEntry('README.md')).toBe(false);
expect(shouldFilterHiddenEntry('package.json')).toBe(false);
});
it('should handle edge cases correctly', () => {
delete process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN;
expect(shouldFilterHiddenEntry('')).toBe(false); // empty string
expect(shouldFilterHiddenEntry('.')).toBe(true); // single dot
expect(shouldFilterHiddenEntry('..')).toBe(true); // double dot
expect(shouldFilterHiddenEntry('name.')).toBe(false); // dot at end
expect(shouldFilterHiddenEntry('name.with.dots')).toBe(false); // dots in middle
});
});
});

View File

@@ -29,6 +29,7 @@ import {
tailFile,
headFile,
setAllowedDirectories,
shouldFilterHiddenEntry,
} from './lib.js';
// Command line argument parsing
@@ -449,6 +450,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const validPath = await validatePath(parsed.data.path);
const entries = await fs.readdir(validPath, { withFileTypes: true });
const formatted = entries
.filter((entry) => !shouldFilterHiddenEntry(entry.name))
.map((entry) => `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`)
.join("\n");
return {
@@ -464,27 +466,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const validPath = await validatePath(parsed.data.path);
const entries = await fs.readdir(validPath, { withFileTypes: true });
// Get detailed information for each entry
// Get detailed information for each entry (excluding hidden entries)
const detailedEntries = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(validPath, entry.name);
try {
const stats = await fs.stat(entryPath);
return {
name: entry.name,
isDirectory: entry.isDirectory(),
size: stats.size,
mtime: stats.mtime
};
} catch (error) {
return {
name: entry.name,
isDirectory: entry.isDirectory(),
size: 0,
mtime: new Date(0)
};
}
})
entries
.filter((entry) => !shouldFilterHiddenEntry(entry.name))
.map(async (entry) => {
const entryPath = path.join(validPath, entry.name);
try {
const stats = await fs.stat(entryPath);
return {
name: entry.name,
isDirectory: entry.isDirectory(),
size: stats.size,
mtime: stats.mtime
};
} catch (error) {
return {
name: entry.name,
isDirectory: entry.isDirectory(),
size: 0,
mtime: new Date(0)
};
}
})
);
// Sort entries based on sortBy parameter
@@ -541,6 +545,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const result: TreeEntry[] = [];
for (const entry of entries) {
// Skip hidden files/directories unless explicitly enabled
if (shouldFilterHiddenEntry(entry.name)) continue;
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
const shouldExclude = excludePatterns.some(pattern => {
if (pattern.includes('*')) {

View File

@@ -40,6 +40,17 @@ export interface SearchResult {
isDirectory: boolean;
}
// Check if hidden files/directories should be included
// Environment variable MCP_FILESYSTEM_INCLUDE_HIDDEN controls this (default: false)
export function shouldIncludeHidden(): boolean {
return process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN === 'true';
}
// Check if a file/directory name should be filtered out (dot-prefixed items)
export function shouldFilterHiddenEntry(name: string): boolean {
return !shouldIncludeHidden() && name.startsWith('.');
}
// Pure Utility Functions
export function formatSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
@@ -361,6 +372,9 @@ export async function searchFilesWithValidation(
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
// Skip hidden files/directories unless explicitly enabled
if (shouldFilterHiddenEntry(entry.name)) continue;
const fullPath = path.join(currentPath, entry.name);
try {