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>
This commit is contained in:
parent
46d0b1f926
commit
d381cf1ffd
3 changed files with 180 additions and 3 deletions
|
|
@ -153,6 +153,19 @@ The server's directory access control follows this flow:
|
||||||
- Case-insensitive matching
|
- Case-insensitive matching
|
||||||
- Returns full paths to matches
|
- 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_file_info**
|
||||||
- Get detailed file/directory metadata
|
- Get detailed file/directory metadata
|
||||||
- Input: `path` (string)
|
- Input: `path` (string)
|
||||||
|
|
|
||||||
147
src/filesystem/__tests__/directory-tree.test.ts
Normal file
147
src/filesystem/__tests__/directory-tree.test.ts
Normal file
|
|
@ -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<TreeEntry[]> {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,6 +14,7 @@ import { createReadStream } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||||
|
import { minimatch } from "minimatch";
|
||||||
import { normalizePath, expandHome } from './path-utils.js';
|
import { normalizePath, expandHome } from './path-utils.js';
|
||||||
import { getValidRootDirectories } from './roots-utils.js';
|
import { getValidRootDirectories } from './roots-utils.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -121,6 +122,7 @@ const ListDirectoryWithSizesArgsSchema = z.object({
|
||||||
|
|
||||||
const DirectoryTreeArgsSchema = z.object({
|
const DirectoryTreeArgsSchema = z.object({
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
|
excludePatterns: z.array(z.string()).optional().default([])
|
||||||
});
|
});
|
||||||
|
|
||||||
const MoveFileArgsSchema = z.object({
|
const MoveFileArgsSchema = z.object({
|
||||||
|
|
@ -528,13 +530,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
type: 'file' | 'directory';
|
type: 'file' | 'directory';
|
||||||
children?: TreeEntry[];
|
children?: TreeEntry[];
|
||||||
}
|
}
|
||||||
|
const rootPath = parsed.data.path;
|
||||||
|
|
||||||
async function buildTree(currentPath: string): Promise<TreeEntry[]> {
|
async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {
|
||||||
const validPath = await validatePath(currentPath);
|
const validPath = await validatePath(currentPath);
|
||||||
const entries = await fs.readdir(validPath, {withFileTypes: true});
|
const entries = await fs.readdir(validPath, {withFileTypes: true});
|
||||||
const result: TreeEntry[] = [];
|
const result: TreeEntry[] = [];
|
||||||
|
|
||||||
for (const entry of entries) {
|
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 = {
|
const entryData: TreeEntry = {
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
type: entry.isDirectory() ? 'directory' : 'file'
|
type: entry.isDirectory() ? 'directory' : 'file'
|
||||||
|
|
@ -542,7 +559,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
const subPath = path.join(currentPath, entry.name);
|
const subPath = path.join(currentPath, entry.name);
|
||||||
entryData.children = await buildTree(subPath);
|
entryData.children = await buildTree(subPath, excludePatterns);
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push(entryData);
|
result.push(entryData);
|
||||||
|
|
@ -551,7 +568,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const treeData = await buildTree(parsed.data.path);
|
const treeData = await buildTree(rootPath, parsed.data.excludePatterns);
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue