Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/filesystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
147 changes: 147 additions & 0 deletions src/filesystem/__tests__/directory-tree.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
23 changes: 20 additions & 3 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -528,21 +530,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
type: 'file' | 'directory';
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 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'
};

if (entry.isDirectory()) {
const subPath = path.join(currentPath, entry.name);
entryData.children = await buildTree(subPath);
entryData.children = await buildTree(subPath, excludePatterns);
}

result.push(entryData);
Expand All @@ -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",
Expand Down