diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 499fca5ad6..75236aaa9c 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -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 diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index cc13ef0353..9f25c7ce75 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -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 + }); + }); +}); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 7888196285..1529fd1760 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -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('*')) { diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 240ca0d476..697db77773 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -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 {