diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 0f456f3cd2..ccef2d6708 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -153,6 +153,19 @@ The server's directory access control follows this flow: - 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/__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 310cfa6bba..ee4307c474 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -14,6 +14,7 @@ import { createReadStream } from "fs"; import path from "path"; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; +import { minimatch } from "minimatch"; import { normalizePath, expandHome } from './path-utils.js'; import { getValidRootDirectories } from './roots-utils.js'; import { @@ -121,6 +122,7 @@ const ListDirectoryWithSizesArgsSchema = z.object({ const DirectoryTreeArgsSchema = z.object({ path: z.string(), + excludePatterns: z.array(z.string()).optional().default([]) }); const MoveFileArgsSchema = z.object({ @@ -528,13 +530,28 @@ 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 => { + 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' @@ -542,7 +559,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); @@ -551,7 +568,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",