From 806d974fab6cf156dd6abdbd2f17eac6a9195295 Mon Sep 17 00:00:00 2001 From: lamemind Date: Thu, 13 Feb 2025 22:04:46 +0100 Subject: [PATCH 1/2] feat(directory_tree): add excludePatterns support & documentation - Update documentation with directory_tree declaration - Add excludePatterns parameter to DirectoryTreeArgsSchema - Implement pattern exclusion in buildTree function using minimatch - Pass excludePatterns through recursive calls - Support both simple and glob patterns for exclusion - Maintain consistent behavior with search_files implementation --- src/filesystem/README.md | 13 +++++++++++++ src/filesystem/index.ts | 16 +++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/filesystem/README.md b/src/filesystem/README.md index c52f1a4041..0578572265 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -86,6 +86,19 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio - Case-insensitive matching - Returns full paths to matches +- **directory_tree** + - Get recursive JSON tree structure of directory contents + - Inputs: + - `path` (string): Starting directory + - `excludePatterns` (string[]): Exclude any patterns. Glob formats are supported. + - Returns: + - JSON array where each entry contains: + - `name` (string): File/directory name + - `type` ('file'|'directory'): Entry type + - `children` (array): Present only for directories + - Empty array for empty directories + - Omitted for files + - **get_file_info** - Get detailed file/directory metadata - Input: `path` (string) diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index b4d5c419fc..b4b3492097 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -129,6 +129,7 @@ const ListDirectoryArgsSchema = z.object({ const DirectoryTreeArgsSchema = z.object({ path: z.string(), + excludePatterns: z.array(z.string()).optional().default([]) }); const MoveFileArgsSchema = z.object({ @@ -541,13 +542,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { type: 'file' | 'directory'; children?: TreeEntry[]; } + const rootPath = parsed.data.path; - async function buildTree(currentPath: string): Promise { + async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise { const validPath = await validatePath(currentPath); const entries = await fs.readdir(validPath, {withFileTypes: true}); const result: TreeEntry[] = []; for (const entry of entries) { + const relativePath = path.relative(rootPath, path.join(currentPath, entry.name)); + const shouldExclude = excludePatterns.some(pattern => { + const globPattern = pattern.includes('*') ? pattern : `**/${pattern}/**`; + return minimatch(relativePath, globPattern, {dot: true}); + }); + if (shouldExclude) + continue; + const entryData: TreeEntry = { name: entry.name, type: entry.isDirectory() ? 'directory' : 'file' @@ -555,7 +565,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (entry.isDirectory()) { const subPath = path.join(currentPath, entry.name); - entryData.children = await buildTree(subPath); + entryData.children = await buildTree(subPath, excludePatterns); } result.push(entryData); @@ -564,7 +574,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return result; } - const treeData = await buildTree(parsed.data.path); + const treeData = await buildTree(rootPath, parsed.data.excludePatterns); return { content: [{ type: "text", From 92065f371a128941e1fb28c55e11ef390124fa4e Mon Sep 17 00:00:00 2001 From: Adam Jones Date: Fri, 15 Aug 2025 17:07:10 +0100 Subject: [PATCH 2/2] Add tests and fix implementation --- .../__tests__/directory-tree.test.ts | 147 ++++++++++++++++++ src/filesystem/index.ts | 10 +- 2 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 src/filesystem/__tests__/directory-tree.test.ts diff --git a/src/filesystem/__tests__/directory-tree.test.ts b/src/filesystem/__tests__/directory-tree.test.ts new file mode 100644 index 0000000000..6828650cc0 --- /dev/null +++ b/src/filesystem/__tests__/directory-tree.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; + +// We need to test the buildTree function, but it's defined inside the request handler +// So we'll extract the core logic into a testable function +import { minimatch } from 'minimatch'; + +interface TreeEntry { + name: string; + type: 'file' | 'directory'; + children?: TreeEntry[]; +} + +async function buildTreeForTesting(currentPath: string, rootPath: string, excludePatterns: string[] = []): Promise { + const entries = await fs.readdir(currentPath, {withFileTypes: true}); + const result: TreeEntry[] = []; + + 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}); + }); + 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); + } + + result.push(entryData); + } + + return result; +} + +describe('buildTree exclude patterns', () => { + let testDir: string; + + beforeEach(async () => { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'filesystem-test-')); + + // Create test directory structure + await fs.mkdir(path.join(testDir, 'src')); + await fs.mkdir(path.join(testDir, 'node_modules')); + await fs.mkdir(path.join(testDir, '.git')); + await fs.mkdir(path.join(testDir, 'nested', 'node_modules'), { recursive: true }); + + // Create test files + await fs.writeFile(path.join(testDir, '.env'), 'SECRET=value'); + await fs.writeFile(path.join(testDir, '.env.local'), 'LOCAL_SECRET=value'); + await fs.writeFile(path.join(testDir, 'src', 'index.js'), 'console.log("hello");'); + await fs.writeFile(path.join(testDir, 'package.json'), '{}'); + await fs.writeFile(path.join(testDir, 'node_modules', 'module.js'), 'module.exports = {};'); + await fs.writeFile(path.join(testDir, 'nested', 'node_modules', 'deep.js'), 'module.exports = {};'); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it('should exclude files matching simple patterns', async () => { + // Test the current implementation - this will fail until the bug is fixed + const tree = await buildTreeForTesting(testDir, testDir, ['.env']); + const fileNames = tree.map(entry => entry.name); + + expect(fileNames).not.toContain('.env'); + expect(fileNames).toContain('.env.local'); // Should not exclude this + expect(fileNames).toContain('src'); + expect(fileNames).toContain('package.json'); + }); + + it('should exclude directories matching simple patterns', async () => { + const tree = await buildTreeForTesting(testDir, testDir, ['node_modules']); + const dirNames = tree.map(entry => entry.name); + + expect(dirNames).not.toContain('node_modules'); + expect(dirNames).toContain('src'); + expect(dirNames).toContain('.git'); + }); + + it('should exclude nested directories with same pattern', async () => { + const tree = await buildTreeForTesting(testDir, testDir, ['node_modules']); + + // Find the nested directory + const nestedDir = tree.find(entry => entry.name === 'nested'); + expect(nestedDir).toBeDefined(); + expect(nestedDir!.children).toBeDefined(); + + // The nested/node_modules should also be excluded + const nestedChildren = nestedDir!.children!.map(child => child.name); + expect(nestedChildren).not.toContain('node_modules'); + }); + + it('should handle glob patterns correctly', async () => { + const tree = await buildTreeForTesting(testDir, testDir, ['*.env']); + const fileNames = tree.map(entry => entry.name); + + expect(fileNames).not.toContain('.env'); + expect(fileNames).toContain('.env.local'); // *.env should not match .env.local + expect(fileNames).toContain('src'); + }); + + it('should handle dot files correctly', async () => { + const tree = await buildTreeForTesting(testDir, testDir, ['.git']); + const dirNames = tree.map(entry => entry.name); + + expect(dirNames).not.toContain('.git'); + expect(dirNames).toContain('.env'); // Should not exclude this + }); + + it('should work with multiple exclude patterns', async () => { + const tree = await buildTreeForTesting(testDir, testDir, ['node_modules', '.env', '.git']); + const entryNames = tree.map(entry => entry.name); + + expect(entryNames).not.toContain('node_modules'); + expect(entryNames).not.toContain('.env'); + expect(entryNames).not.toContain('.git'); + expect(entryNames).toContain('src'); + expect(entryNames).toContain('package.json'); + }); + + it('should handle empty exclude patterns', async () => { + const tree = await buildTreeForTesting(testDir, testDir, []); + const entryNames = tree.map(entry => entry.name); + + // All entries should be included + expect(entryNames).toContain('node_modules'); + expect(entryNames).toContain('.env'); + expect(entryNames).toContain('.git'); + expect(entryNames).toContain('src'); + }); +}); \ No newline at end of file diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index b4a51f0f1e..e4d1be75a0 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -884,8 +884,14 @@ 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 => { - const globPattern = pattern.includes('*') ? pattern : `**/${pattern}/**`; - return minimatch(relativePath, globPattern, {dot: true}); + 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}); }); if (shouldExclude) continue;