Skip to content

Commit d381cf1

Browse files
lamemindolaservodomdomegg
authored
feat(directory_tree): add excludePatterns support & documentation (#623)
- 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 * Add tests and fix implementation --------- Co-authored-by: Ola Hungerford <olahungerford@gmail.com> Co-authored-by: Adam Jones <adamj+git@anthropic.com> Co-authored-by: Adam Jones <adamj@anthropic.com>
1 parent 46d0b1f commit d381cf1

3 files changed

Lines changed: 180 additions & 3 deletions

File tree

src/filesystem/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,19 @@ The server's directory access control follows this flow:
153153
- Case-insensitive matching
154154
- Returns full paths to matches
155155

156+
- **directory_tree**
157+
- Get recursive JSON tree structure of directory contents
158+
- Inputs:
159+
- `path` (string): Starting directory
160+
- `excludePatterns` (string[]): Exclude any patterns. Glob formats are supported.
161+
- Returns:
162+
- JSON array where each entry contains:
163+
- `name` (string): File/directory name
164+
- `type` ('file'|'directory'): Entry type
165+
- `children` (array): Present only for directories
166+
- Empty array for empty directories
167+
- Omitted for files
168+
156169
- **get_file_info**
157170
- Get detailed file/directory metadata
158171
- Input: `path` (string)
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
2+
import * as fs from 'fs/promises';
3+
import * as path from 'path';
4+
import * as os from 'os';
5+
6+
// We need to test the buildTree function, but it's defined inside the request handler
7+
// So we'll extract the core logic into a testable function
8+
import { minimatch } from 'minimatch';
9+
10+
interface TreeEntry {
11+
name: string;
12+
type: 'file' | 'directory';
13+
children?: TreeEntry[];
14+
}
15+
16+
async function buildTreeForTesting(currentPath: string, rootPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {
17+
const entries = await fs.readdir(currentPath, {withFileTypes: true});
18+
const result: TreeEntry[] = [];
19+
20+
for (const entry of entries) {
21+
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
22+
const shouldExclude = excludePatterns.some(pattern => {
23+
if (pattern.includes('*')) {
24+
return minimatch(relativePath, pattern, {dot: true});
25+
}
26+
// For files: match exact name or as part of path
27+
// For directories: match as directory path
28+
return minimatch(relativePath, pattern, {dot: true}) ||
29+
minimatch(relativePath, `**/${pattern}`, {dot: true}) ||
30+
minimatch(relativePath, `**/${pattern}/**`, {dot: true});
31+
});
32+
if (shouldExclude)
33+
continue;
34+
35+
const entryData: TreeEntry = {
36+
name: entry.name,
37+
type: entry.isDirectory() ? 'directory' : 'file'
38+
};
39+
40+
if (entry.isDirectory()) {
41+
const subPath = path.join(currentPath, entry.name);
42+
entryData.children = await buildTreeForTesting(subPath, rootPath, excludePatterns);
43+
}
44+
45+
result.push(entryData);
46+
}
47+
48+
return result;
49+
}
50+
51+
describe('buildTree exclude patterns', () => {
52+
let testDir: string;
53+
54+
beforeEach(async () => {
55+
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'filesystem-test-'));
56+
57+
// Create test directory structure
58+
await fs.mkdir(path.join(testDir, 'src'));
59+
await fs.mkdir(path.join(testDir, 'node_modules'));
60+
await fs.mkdir(path.join(testDir, '.git'));
61+
await fs.mkdir(path.join(testDir, 'nested', 'node_modules'), { recursive: true });
62+
63+
// Create test files
64+
await fs.writeFile(path.join(testDir, '.env'), 'SECRET=value');
65+
await fs.writeFile(path.join(testDir, '.env.local'), 'LOCAL_SECRET=value');
66+
await fs.writeFile(path.join(testDir, 'src', 'index.js'), 'console.log("hello");');
67+
await fs.writeFile(path.join(testDir, 'package.json'), '{}');
68+
await fs.writeFile(path.join(testDir, 'node_modules', 'module.js'), 'module.exports = {};');
69+
await fs.writeFile(path.join(testDir, 'nested', 'node_modules', 'deep.js'), 'module.exports = {};');
70+
});
71+
72+
afterEach(async () => {
73+
await fs.rm(testDir, { recursive: true, force: true });
74+
});
75+
76+
it('should exclude files matching simple patterns', async () => {
77+
// Test the current implementation - this will fail until the bug is fixed
78+
const tree = await buildTreeForTesting(testDir, testDir, ['.env']);
79+
const fileNames = tree.map(entry => entry.name);
80+
81+
expect(fileNames).not.toContain('.env');
82+
expect(fileNames).toContain('.env.local'); // Should not exclude this
83+
expect(fileNames).toContain('src');
84+
expect(fileNames).toContain('package.json');
85+
});
86+
87+
it('should exclude directories matching simple patterns', async () => {
88+
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules']);
89+
const dirNames = tree.map(entry => entry.name);
90+
91+
expect(dirNames).not.toContain('node_modules');
92+
expect(dirNames).toContain('src');
93+
expect(dirNames).toContain('.git');
94+
});
95+
96+
it('should exclude nested directories with same pattern', async () => {
97+
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules']);
98+
99+
// Find the nested directory
100+
const nestedDir = tree.find(entry => entry.name === 'nested');
101+
expect(nestedDir).toBeDefined();
102+
expect(nestedDir!.children).toBeDefined();
103+
104+
// The nested/node_modules should also be excluded
105+
const nestedChildren = nestedDir!.children!.map(child => child.name);
106+
expect(nestedChildren).not.toContain('node_modules');
107+
});
108+
109+
it('should handle glob patterns correctly', async () => {
110+
const tree = await buildTreeForTesting(testDir, testDir, ['*.env']);
111+
const fileNames = tree.map(entry => entry.name);
112+
113+
expect(fileNames).not.toContain('.env');
114+
expect(fileNames).toContain('.env.local'); // *.env should not match .env.local
115+
expect(fileNames).toContain('src');
116+
});
117+
118+
it('should handle dot files correctly', async () => {
119+
const tree = await buildTreeForTesting(testDir, testDir, ['.git']);
120+
const dirNames = tree.map(entry => entry.name);
121+
122+
expect(dirNames).not.toContain('.git');
123+
expect(dirNames).toContain('.env'); // Should not exclude this
124+
});
125+
126+
it('should work with multiple exclude patterns', async () => {
127+
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules', '.env', '.git']);
128+
const entryNames = tree.map(entry => entry.name);
129+
130+
expect(entryNames).not.toContain('node_modules');
131+
expect(entryNames).not.toContain('.env');
132+
expect(entryNames).not.toContain('.git');
133+
expect(entryNames).toContain('src');
134+
expect(entryNames).toContain('package.json');
135+
});
136+
137+
it('should handle empty exclude patterns', async () => {
138+
const tree = await buildTreeForTesting(testDir, testDir, []);
139+
const entryNames = tree.map(entry => entry.name);
140+
141+
// All entries should be included
142+
expect(entryNames).toContain('node_modules');
143+
expect(entryNames).toContain('.env');
144+
expect(entryNames).toContain('.git');
145+
expect(entryNames).toContain('src');
146+
});
147+
});

src/filesystem/index.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createReadStream } from "fs";
1414
import path from "path";
1515
import { z } from "zod";
1616
import { zodToJsonSchema } from "zod-to-json-schema";
17+
import { minimatch } from "minimatch";
1718
import { normalizePath, expandHome } from './path-utils.js';
1819
import { getValidRootDirectories } from './roots-utils.js';
1920
import {
@@ -121,6 +122,7 @@ const ListDirectoryWithSizesArgsSchema = z.object({
121122

122123
const DirectoryTreeArgsSchema = z.object({
123124
path: z.string(),
125+
excludePatterns: z.array(z.string()).optional().default([])
124126
});
125127

126128
const MoveFileArgsSchema = z.object({
@@ -528,21 +530,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
528530
type: 'file' | 'directory';
529531
children?: TreeEntry[];
530532
}
533+
const rootPath = parsed.data.path;
531534

532-
async function buildTree(currentPath: string): Promise<TreeEntry[]> {
535+
async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {
533536
const validPath = await validatePath(currentPath);
534537
const entries = await fs.readdir(validPath, {withFileTypes: true});
535538
const result: TreeEntry[] = [];
536539

537540
for (const entry of entries) {
541+
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
542+
const shouldExclude = excludePatterns.some(pattern => {
543+
if (pattern.includes('*')) {
544+
return minimatch(relativePath, pattern, {dot: true});
545+
}
546+
// For files: match exact name or as part of path
547+
// For directories: match as directory path
548+
return minimatch(relativePath, pattern, {dot: true}) ||
549+
minimatch(relativePath, `**/${pattern}`, {dot: true}) ||
550+
minimatch(relativePath, `**/${pattern}/**`, {dot: true});
551+
});
552+
if (shouldExclude)
553+
continue;
554+
538555
const entryData: TreeEntry = {
539556
name: entry.name,
540557
type: entry.isDirectory() ? 'directory' : 'file'
541558
};
542559

543560
if (entry.isDirectory()) {
544561
const subPath = path.join(currentPath, entry.name);
545-
entryData.children = await buildTree(subPath);
562+
entryData.children = await buildTree(subPath, excludePatterns);
546563
}
547564

548565
result.push(entryData);
@@ -551,7 +568,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
551568
return result;
552569
}
553570

554-
const treeData = await buildTree(parsed.data.path);
571+
const treeData = await buildTree(rootPath, parsed.data.excludePatterns);
555572
return {
556573
content: [{
557574
type: "text",

0 commit comments

Comments
 (0)