Skip to content
Open
14 changes: 11 additions & 3 deletions src/filesystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio
- Get file metadata
- Dynamic directory access control via [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots)

## Breaking Changes

### search_files Tool Changes (September 2025)
The `search_files` tool has been updated with the following breaking changes:
- **Parameter change**: The `pattern` parameter (string) has been replaced with `patterns` parameter (string[])
- **Behavior change**: Now uses glob pattern matching exclusively instead of mixed glob/substring matching
- **Migration**: Update calls from `{"pattern": "*.js"}` to `{"patterns": ["*.js"]}` and ensure patterns use proper glob syntax

## Directory Access Control

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).
Expand Down Expand Up @@ -136,12 +144,12 @@ The server's directory access control follows this flow:
- Fails if destination exists

- **search_files**
- Recursively search for files/directories that match or do not match patterns
- Recursively search for files/directories that match glob patterns
- Inputs:
- `path` (string): Starting directory
- `pattern` (string): Search pattern
- `patterns` (string[]): Array of glob patterns to match
- `excludePatterns` (string[]): Exclude any patterns.
- Glob-style pattern matching
- Glob-style pattern matching exclusively
- Returns full paths to matches

- **directory_tree**
Expand Down
25 changes: 11 additions & 14 deletions src/filesystem/__tests__/directory-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,10 @@ async function buildTreeForTesting(currentPath: string, rootPath: string, exclud

for (const entry of entries) {
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
const shouldExclude = excludePatterns.some(pattern => {
if (pattern.includes('*')) {
return minimatch(relativePath, pattern, {dot: true});
}
// For files: match exact name or as part of path
// For directories: match as directory path
return minimatch(relativePath, pattern, {dot: true}) ||
minimatch(relativePath, `**/${pattern}`, {dot: true}) ||
minimatch(relativePath, `**/${pattern}/**`, {dot: true});
});
// Use glob matching exclusively for consistency
const shouldExclude = excludePatterns.some(pattern =>
minimatch(relativePath, pattern, {dot: true})
);
if (shouldExclude)
continue;

Expand Down Expand Up @@ -74,7 +68,7 @@ describe('buildTree exclude patterns', () => {
});

it('should exclude files matching simple patterns', async () => {
// Test the current implementation - this will fail until the bug is fixed
// With strict glob matching, '.env' only matches exactly '.env'
const tree = await buildTreeForTesting(testDir, testDir, ['.env']);
const fileNames = tree.map(entry => entry.name);

Expand All @@ -85,6 +79,7 @@ describe('buildTree exclude patterns', () => {
});

it('should exclude directories matching simple patterns', async () => {
// With strict glob matching, 'node_modules' only matches top-level
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules']);
const dirNames = tree.map(entry => entry.name);

Expand All @@ -93,8 +88,9 @@ describe('buildTree exclude patterns', () => {
expect(dirNames).toContain('.git');
});

it('should exclude nested directories with same pattern', async () => {
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules']);
it('should exclude nested directories with glob pattern', async () => {
// Use '**/node_modules' to match at any level
const tree = await buildTreeForTesting(testDir, testDir, ['**/node_modules']);

// Find the nested directory
const nestedDir = tree.find(entry => entry.name === 'nested');
Expand Down Expand Up @@ -124,7 +120,8 @@ describe('buildTree exclude patterns', () => {
});

it('should work with multiple exclude patterns', async () => {
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules', '.env', '.git']);
// Mix of exact matches and glob patterns
const tree = await buildTreeForTesting(testDir, testDir, ['**/node_modules', '.env', '.git']);
const entryNames = tree.map(entry => entry.name);

expect(entryNames).not.toContain('node_modules');
Expand Down
6 changes: 3 additions & 3 deletions src/filesystem/__tests__/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ describe('Lib Functions', () => {

const result = await searchFilesWithValidation(
testDir,
'*test*',
['*test*'],
allowedDirs,
{ excludePatterns: ['*.log', 'node_modules'] }
);
Expand Down Expand Up @@ -346,7 +346,7 @@ describe('Lib Functions', () => {

const result = await searchFilesWithValidation(
testDir,
'*test*',
['*test*'],
allowedDirs,
{}
);
Expand All @@ -370,7 +370,7 @@ describe('Lib Functions', () => {

const result = await searchFilesWithValidation(
testDir,
'*test*',
['*test*'],
allowedDirs,
{ excludePatterns: ['*.backup'] }
);
Expand Down
30 changes: 15 additions & 15 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ const MoveFileArgsSchema = z.object({

const SearchFilesArgsSchema = z.object({
path: z.string(),
pattern: z.string(),
patterns: z.array(z.string()),
excludePatterns: z.array(z.string()).optional().default([])
});

Expand Down Expand Up @@ -264,6 +264,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
"Get a recursive tree view of files and directories as a JSON structure. " +
"Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " +
"Files have no children array, while directories always have a children array (which may be empty). " +
"Supports excluding paths using glob patterns (e.g., ['node_modules', '**/*.log', '.git']). " +
"The output is formatted with 2-space indentation for readability. Only works within allowed directories.",
inputSchema: zodToJsonSchema(DirectoryTreeArgsSchema) as ToolInput,
},
Expand All @@ -279,9 +280,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
{
name: "search_files",
description:
"Recursively search for files and directories matching a pattern. " +
"The patterns should be glob-style patterns that match paths relative to the working directory. " +
"Use pattern like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. " +
"Recursively search for files and directories by name matching a glob pattern. " +
"Supports glob-style patterns that match paths relative to the working directory. " +
"Use patterns like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. " +
"Returns full paths to all matching items. Great for finding files when you don't know their exact location. " +
"Only searches within allowed directories. " +
"The patterns should be glob-style patterns that match paths relative to the search path. " +
"Use patterns like ['*.ext'] to match files in current directory, and ['**/*.ext'] to match files in all subdirectories. " +
"Multiple patterns can be provided to match different file types, e.g., ['**/*.js', '**/*.ts']. " +
"Returns full paths to all matching items. Great for finding files when you don't know their exact location. " +
"Only searches within allowed directories.",
inputSchema: zodToJsonSchema(SearchFilesArgsSchema) as ToolInput,
Expand Down Expand Up @@ -542,16 +548,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {

for (const entry of entries) {
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
const shouldExclude = excludePatterns.some(pattern => {
if (pattern.includes('*')) {
return minimatch(relativePath, pattern, {dot: true});
}
// For files: match exact name or as part of path
// For directories: match as directory path
return minimatch(relativePath, pattern, {dot: true}) ||
minimatch(relativePath, `**/${pattern}`, {dot: true}) ||
minimatch(relativePath, `**/${pattern}/**`, {dot: true});
});
// Use glob matching exclusively for consistency
const shouldExclude = excludePatterns.some(pattern =>
minimatch(relativePath, pattern, {dot: true})
);
if (shouldExclude)
continue;

Expand Down Expand Up @@ -599,7 +599,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
throw new Error(`Invalid arguments for search_files: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
const results = await searchFilesWithValidation(validPath, parsed.data.pattern, allowedDirectories, { excludePatterns: parsed.data.excludePatterns });
const results = await searchFilesWithValidation(validPath, parsed.data.patterns, allowedDirectories, { excludePatterns: parsed.data.excludePatterns });
return {
content: [{ type: "text", text: results.length > 0 ? results.join("\n") : "No matches found" }],
};
Expand Down
10 changes: 7 additions & 3 deletions src/filesystem/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ export async function headFile(filePath: string, numLines: number): Promise<stri

export async function searchFilesWithValidation(
rootPath: string,
pattern: string,
patterns: string[],
allowedDirectories: string[],
options: SearchOptions = {}
): Promise<string[]> {
Expand All @@ -373,8 +373,12 @@ export async function searchFilesWithValidation(

if (shouldExclude) continue;

// Use glob matching for the search pattern
if (minimatch(relativePath, pattern, { dot: true })) {
// Check if the path matches any of the patterns
const matches = patterns.some(pattern =>
minimatch(relativePath, pattern, { dot: true })
);

if (matches) {
results.push(fullPath);
}

Expand Down