diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 973f76ef66..bd72eb9911 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -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). @@ -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** diff --git a/src/filesystem/__tests__/directory-tree.test.ts b/src/filesystem/__tests__/directory-tree.test.ts index 6828650cc0..ed0f9451ab 100644 --- a/src/filesystem/__tests__/directory-tree.test.ts +++ b/src/filesystem/__tests__/directory-tree.test.ts @@ -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; @@ -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); @@ -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); @@ -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'); @@ -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'); diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index cc13ef0353..1e83e2628a 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -316,7 +316,7 @@ describe('Lib Functions', () => { const result = await searchFilesWithValidation( testDir, - '*test*', + ['*test*'], allowedDirs, { excludePatterns: ['*.log', 'node_modules'] } ); @@ -346,7 +346,7 @@ describe('Lib Functions', () => { const result = await searchFilesWithValidation( testDir, - '*test*', + ['*test*'], allowedDirs, {} ); @@ -370,7 +370,7 @@ describe('Lib Functions', () => { const result = await searchFilesWithValidation( testDir, - '*test*', + ['*test*'], allowedDirs, { excludePatterns: ['*.backup'] } ); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 7888196285..7f7ea8e857 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -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([]) }); @@ -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, }, @@ -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, @@ -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; @@ -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" }], }; diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 240ca0d476..cb68feddc2 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -350,7 +350,7 @@ export async function headFile(filePath: string, numLines: number): Promise { @@ -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); }