Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions src/filesystem/__tests__/hidden-files.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
131 changes: 131 additions & 0 deletions src/filesystem/__tests__/hidden-tree.test.ts
Original file line number Diff line number Diff line change
@@ -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<TreeEntry[]> {
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
});
});
14 changes: 12 additions & 2 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -463,7 +469,8 @@ server.registerTool(
},
async (args: z.infer<typeof ListDirectoryWithSizesArgsSchema>) => {
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(
Expand Down Expand Up @@ -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('*')) {
Expand Down Expand Up @@ -643,7 +653,7 @@ server.registerTool(
},
async (args: z.infer<typeof SearchFilesArgsSchema>) => {
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 }],
Expand Down
6 changes: 5 additions & 1 deletion src/filesystem/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface FileInfo {

export interface SearchOptions {
excludePatterns?: string[];
includeHidden?: boolean;
}

export interface SearchResult {
Expand Down Expand Up @@ -377,13 +378,16 @@ export async function searchFilesWithValidation(
allowedDirectories: string[],
options: SearchOptions = {}
): Promise<string[]> {
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 {
Expand Down
Loading