Repository navigation
filesystem: search_files does not expand non-wildcard excludePatterns like directory_tree does #5216
Copy link
Copy link
Open
Description
Summary
While auditing src/filesystem on v2.0.0 (origin/main) following the #4857 spec refactor, I found a behavioral mismatch in how excludePatterns is evaluated between directory_tree (src/filesystem/server.ts) and search_files (src/filesystem/lib.ts).
When an LLM or user passes a bare directory or file name in excludePatterns (for example, ["node_modules", ".git"]), directory_tree excludes both the directory and its entire subtree, whereas search_files still traverses into the excluded directory and returns all files inside it.
Server & Environment
- Server:
filesystem(@modelcontextprotocol/server-filesystemv2.0.0/origin/main) - Files:
src/filesystem/server.ts(buildTree) vs.src/filesystem/lib.ts(searchFilesWithValidation) - Protocol era / transport: Both
2026-07-28and2025-eraoverstdio/ in-memory client transport
Reproduction / Root Cause in v2/main
- In
directory_tree(src/filesystem/server.ts, lines 515–529),buildTreechecks whether an exclusion pattern contains*. If it does not, it automatically expands the pattern to match nested paths and subtrees (**/${pattern}and**/${pattern}/**), and skips recursing into the excluded directory:
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;- In
search_files(src/filesystem/lib.ts, lines 397–413),searchFilesWithValidationevaluatesminimatch(relativePath, excludePattern, { dot: true })directly without the non-wildcard expansion:
const relativePath = path.relative(rootPath, fullPath);
const shouldExclude = excludePatterns.some((excludePattern) =>
minimatch(relativePath, excludePattern, { dot: true }),
);
if (shouldExclude) continue;What happens when a caller passes excludePatterns: ["node_modules"] or ["sub"]:
- At
node_modules(top-level):relativePathis"node_modules".minimatch("node_modules", "node_modules")istrue, soif (shouldExclude) continueskips recursing into top-levelnode_modules! - At
packages/app/node_modules(nested directory):relativePathis"packages/app/node_modules".minimatch("packages/app/node_modules", "node_modules")isfalse!search_filesenterspackages/app/node_modulesand traverses every file inside it. - When
excludePatterns: ["node_modules/*"]or["**/node_modules"](without trailing/**) is passed: Even if a pattern matches files inside a directory,search_filesstill wastes I/O recursing unless the directory entry itself matchesshouldExclude.
Expected vs. Actual Behavior
- Expected: Passing
excludePatterns: ["node_modules", ".git"](or a bare filename/directory name without*) tosearch_filesshould behave identically todirectory_tree: any nested directory or file matchingpattern,**/${pattern}, or**/${pattern}/**is excluded and its directory subtree is not traversed. - Actual:
directory_treeexcludes nestedpackages/app/node_modules, whilesearch_filestraverses intopackages/app/node_modulesand returns matches from inside it unless the caller explicitly writes**/node_modules/**.
Prompt to Reproduce Fix in the Agentic Workflow
Per CONTRIBUTING.md, here is the exact prompt to fix and test this in the factory workflow:
Unify `excludePatterns` matching between `directory_tree` (`src/filesystem/server.ts`) and `search_files` (`src/filesystem/lib.ts`):
1. Extract a shared helper (or update `searchFilesWithValidation` in `src/filesystem/lib.ts` to match `buildTree` in `src/filesystem/server.ts`) so that when an entry in `excludePatterns` does not contain `*`, it matches `minimatch(relativePath, pattern, { dot: true }) || minimatch(relativePath, `**/${pattern}`, { dot: true }) || minimatch(relativePath, `**/${pattern}/**`, { dot: true })`.
2. Add a protocol-level test in `src/filesystem/__tests__/server-tools.test.ts` (and unit test in `src/filesystem/__tests__/lib.test.ts`) verifying that `search_files` with a bare name in `excludePatterns` (e.g. `excludePatterns: ["ignored_dir"]`) excludes both top-level `ignored_dir/file.txt` and nested `nested/ignored_dir/file.txt`, matching `directory_tree`'s behavior.
3. Run `npm run format` and `npm run local:gate`.Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
xubenqiang commentedon Oct 27, 2026
I hit this while comparing the two tools and have a fix ready, but this repository's PR creation policy is
collaborators_only, soCreatePullRequestis refused for me. Leaving the work here instead of a PR in case it is useful.The fix extracts the predicate
directory_treealready uses intoisExcludedPath()insrc/filesystem/lib.tsand has bothsearchFilesWithValidationandbuildTreecall it, so a bare name matches at any depth (**/<name>) and prunes the subtree (**/<name>/**), while a pattern containing*keeps matching the relative path as written. Excludes are still checked beforevalidatePath, so an excluded folder is never resolved (the #4162 property).Branch:
xubenqiang/servers:fix/filesystem-search-exclude-patterns(commit 128035e, +changeset).Re-verified on
v2/main: newsearch_filescase withexcludePatterns: ["deeper"]over the existingsub/deeper/low.txtfixture returnssub/mid.txt+top.txt(old code returnedsub/deeper/low.txt), plus unit tests forisExcludedPathcovering depth, no prefix match (node_modules_old), and unchanged wildcard semantics. Package suite: 18 failed / 447 passed here; the same 18 fail on the unmodified checkout (Windows symlink EPERM).tscandeslintclean; commit is DCO-signed.