diff --git a/src/filesystem/__tests__/hidden-files.test.ts b/src/filesystem/__tests__/hidden-files.test.ts new file mode 100644 index 0000000000..ea28e79997 --- /dev/null +++ b/src/filesystem/__tests__/hidden-files.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for Issue #2219 — hidden file filtering in searchFilesWithValidation. + * + * Uses vi.mock('fs/promises') exactly like lib.test.ts does, so all fs calls + * are controlled and there is no real-filesystem I/O. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs/promises'; +import * as path from 'path'; +import { searchFilesWithValidation, setAllowedDirectories } from '../lib.js'; + +vi.mock('fs/promises'); +const mockFs = fs as any; + +const testDir = '/allowed/project'; +const allowedDirs = ['/allowed']; + +describe('searchFilesWithValidation — hidden file filtering (Issue #2219)', () => { + beforeEach(() => { + vi.clearAllMocks(); + setAllowedDirectories(allowedDirs); + mockFs.realpath.mockImplementation(async (p: string) => p); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setAllowedDirectories([]); + }); + + it('excludes dot-prefixed files and directories by default (no includeHidden option)', async () => { + mockFs.readdir.mockResolvedValueOnce([ + { name: 'src', isDirectory: () => true }, + { name: 'visible.txt', isDirectory: () => false }, + { name: '.git', isDirectory: () => true }, + { name: '.env', isDirectory: () => false }, + ]); + + const results = await searchFilesWithValidation(testDir, '**/*', allowedDirs); + const names = results.map(r => path.basename(r)); + + expect(names).toContain('visible.txt'); + expect(names).not.toContain('.git'); + expect(names).not.toContain('.env'); + }); + + it('excludes dot-prefixed entries when includeHidden is explicitly false', async () => { + mockFs.readdir.mockResolvedValueOnce([ + { name: 'README.md', isDirectory: () => false }, + { name: '.terraform', isDirectory: () => true }, + ]); + + const results = await searchFilesWithValidation( + testDir, '**/*', allowedDirs, { includeHidden: false }, + ); + const names = results.map(r => path.basename(r)); + + expect(names).toContain('README.md'); + expect(names).not.toContain('.terraform'); + }); + + it('does not recurse into hidden directories when includeHidden is false', async () => { + mockFs.readdir.mockResolvedValueOnce([ + { name: '.git', isDirectory: () => true }, + ]); + + const results = await searchFilesWithValidation( + testDir, '**/HEAD', allowedDirs, { includeHidden: false }, + ); + + expect(results).toHaveLength(0); + // readdir must have been called only once (for rootPath); .git was never entered + expect(mockFs.readdir).toHaveBeenCalledTimes(1); + }); + + it('includes hidden files and directories when includeHidden is true', async () => { + mockFs.readdir + // Root level + .mockResolvedValueOnce([ + { name: 'visible.txt', isDirectory: () => false }, + { name: '.git', isDirectory: () => true }, + { name: '.env', isDirectory: () => false }, + ]) + // .git children (recursive call) + .mockResolvedValueOnce([ + { name: 'HEAD', isDirectory: () => false }, + ]); + + const results = await searchFilesWithValidation( + testDir, '**/*', allowedDirs, { includeHidden: true }, + ); + const names = results.map(r => path.basename(r)); + + expect(names).toContain('visible.txt'); + expect(names).toContain('.git'); + expect(names).toContain('HEAD'); // traversal must have entered .git + expect(names).toContain('.env'); + }); +}); diff --git a/src/filesystem/__tests__/hidden-tree.test.ts b/src/filesystem/__tests__/hidden-tree.test.ts new file mode 100644 index 0000000000..00fc20dd89 --- /dev/null +++ b/src/filesystem/__tests__/hidden-tree.test.ts @@ -0,0 +1,131 @@ +/** + * Tests for Issue #2219 — hidden file filtering in the directory_tree handler. + * + * Replicates the buildTree logic (as directory-tree.test.ts does) and tests it + * against a real temporary directory. No vi.mock here so real fs calls work. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { minimatch } from 'minimatch'; + +// --------------------------------------------------------------------------- +// Local replica of the directory_tree buildTree logic extended with the new +// `includeHidden` flag introduced by this PR. +// --------------------------------------------------------------------------- +interface TreeEntry { + name: string; + type: 'file' | 'directory'; + children?: TreeEntry[]; +} + +async function buildTreeForTesting( + currentPath: string, + rootPath: string, + excludePatterns: string[] = [], + includeHidden = false, +): Promise { + const entries = await fs.readdir(currentPath, { withFileTypes: true }); + const result: TreeEntry[] = []; + + for (const entry of entries) { + // Guard mirrors the change made to the real handler in index.ts (Issue #2219) + if (!includeHidden && entry.name.startsWith('.')) continue; + + const relativePath = path.relative(rootPath, path.join(currentPath, entry.name)); + const shouldExclude = excludePatterns.some(pattern => { + if (pattern.includes('*')) { + return minimatch(relativePath, pattern, { dot: true }); + } + return ( + minimatch(relativePath, pattern, { dot: true }) || + minimatch(relativePath, `**/${pattern}`, { dot: true }) || + minimatch(relativePath, `**/${pattern}/**`, { dot: true }) + ); + }); + if (shouldExclude) continue; + + const entryData: TreeEntry = { + name: entry.name, + type: entry.isDirectory() ? 'directory' : 'file', + }; + + if (entry.isDirectory()) { + const subPath = path.join(currentPath, entry.name); + entryData.children = await buildTreeForTesting(subPath, rootPath, excludePatterns, includeHidden); + } + + result.push(entryData); + } + + return result; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('buildTree — hidden file filtering (directory_tree handler, Issue #2219)', () => { + let testDir: string; + + beforeEach(async () => { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hidden-tree-test-')); + + await fs.writeFile(path.join(testDir, 'visible.txt'), 'hello'); + await fs.mkdir(path.join(testDir, 'src'), { recursive: true }); + await fs.writeFile(path.join(testDir, 'src', 'app.ts'), 'export {}'); + await fs.mkdir(path.join(testDir, '.git'), { recursive: true }); + await fs.writeFile(path.join(testDir, '.git', 'HEAD'), 'ref: refs/heads/main'); + await fs.mkdir(path.join(testDir, '.terraform'), { recursive: true }); + await fs.writeFile(path.join(testDir, '.terraform', 'config.json'), '{}'); + await fs.writeFile(path.join(testDir, '.env'), 'SECRET=value'); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it('excludes dot-prefixed entries at root level by default (includeHidden=false)', async () => { + const tree = await buildTreeForTesting(testDir, testDir); + const names = tree.map(e => e.name); + + expect(names).toContain('visible.txt'); + expect(names).toContain('src'); + expect(names).not.toContain('.git'); + expect(names).not.toContain('.terraform'); + expect(names).not.toContain('.env'); + }); + + it('does not recurse into hidden directories when includeHidden is false', async () => { + const tree = await buildTreeForTesting(testDir, testDir, [], false); + const allNames = (function flatten(entries: TreeEntry[]): string[] { + return entries.flatMap(e => [e.name, ...(e.children ? flatten(e.children) : [])]); + })(tree); + + expect(allNames).not.toContain('.git'); + expect(allNames).not.toContain('HEAD'); // nested inside .git + expect(allNames).not.toContain('config.json'); // nested inside .terraform + }); + + it('includes hidden entries and their children when includeHidden is true', async () => { + const tree = await buildTreeForTesting(testDir, testDir, [], true); + const names = tree.map(e => e.name); + + expect(names).toContain('.git'); + expect(names).toContain('.terraform'); + expect(names).toContain('.env'); + + const gitEntry = tree.find(e => e.name === '.git'); + expect(gitEntry?.children?.map(c => c.name)).toContain('HEAD'); + }); + + it('excludePatterns and includeHidden work independently of each other', async () => { + // With includeHidden=true, src should still be excluded by pattern + const tree = await buildTreeForTesting(testDir, testDir, ['src'], true); + const names = tree.map(e => e.name); + + expect(names).not.toContain('src'); // excluded by pattern + expect(names).toContain('.git'); // hidden but allowed by includeHidden + }); +}); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 7b67e63e58..c915c4553d 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -38,6 +38,11 @@ if (args.length === 0) { console.error("At least one directory must be provided by EITHER method for the server to operate."); } +// Hidden-file configuration — read once at startup. +// MCP communicates over stdout, so we must use console.error for any diagnostic output. +const INCLUDE_HIDDEN = process.env.MCP_FILESYSTEM_INCLUDE_HIDDEN === 'true'; +console.error(`Hidden files/directories: ${INCLUDE_HIDDEN ? 'included' : 'excluded (default)'}`); + // Store allowed directories in normalized and resolved form // We store BOTH the original path AND the resolved path to handle symlinks correctly // This fixes the macOS /tmp -> /private/tmp symlink issue where users specify /tmp @@ -436,6 +441,7 @@ server.registerTool( const validPath = await validatePath(args.path); const entries = await fs.readdir(validPath, { withFileTypes: true }); const formatted = entries + .filter((entry) => INCLUDE_HIDDEN || !entry.name.startsWith('.')) .map((entry) => `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`) .join("\n"); return { @@ -463,7 +469,8 @@ server.registerTool( }, async (args: z.infer) => { const validPath = await validatePath(args.path); - const entries = await fs.readdir(validPath, { withFileTypes: true }); + const allEntries = await fs.readdir(validPath, { withFileTypes: true }); + const entries = allEntries.filter((entry) => INCLUDE_HIDDEN || !entry.name.startsWith('.')); // Get detailed information for each entry const detailedEntries = await Promise.all( @@ -554,6 +561,9 @@ server.registerTool( const result: TreeEntry[] = []; for (const entry of entries) { + // Skip dot-prefixed entries unless hidden files are explicitly enabled + if (!INCLUDE_HIDDEN && entry.name.startsWith('.')) continue; + const relativePath = path.relative(rootPath, path.join(currentPath, entry.name)); const shouldExclude = excludePatterns.some(pattern => { if (pattern.includes('*')) { @@ -643,7 +653,7 @@ server.registerTool( }, async (args: z.infer) => { const validPath = await validatePath(args.path); - const results = await searchFilesWithValidation(validPath, args.pattern, allowedDirectories, { excludePatterns: args.excludePatterns }); + const results = await searchFilesWithValidation(validPath, args.pattern, allowedDirectories, { excludePatterns: args.excludePatterns, includeHidden: INCLUDE_HIDDEN }); const text = results.length > 0 ? results.join("\n") : "No matches found"; return { content: [{ type: "text" as const, text }], diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 17e4654cd5..49f83177b0 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -33,6 +33,7 @@ interface FileInfo { export interface SearchOptions { excludePatterns?: string[]; + includeHidden?: boolean; } export interface SearchResult { @@ -377,13 +378,16 @@ export async function searchFilesWithValidation( allowedDirectories: string[], options: SearchOptions = {} ): Promise { - const { excludePatterns = [] } = options; + const { excludePatterns = [], includeHidden = false } = options; const results: string[] = []; async function search(currentPath: string) { const entries = await fs.readdir(currentPath, { withFileTypes: true }); for (const entry of entries) { + // Skip dot-prefixed entries unless hidden files are explicitly enabled + if (!includeHidden && entry.name.startsWith('.')) continue; + const fullPath = path.join(currentPath, entry.name); try {