Skip to content

filesystem: search_files does not expand non-wildcard excludePatterns like directory_tree does #5216

Open

Description

@Pranav-Nexus

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-filesystem v2.0.0 / origin/main)
  • Files: src/filesystem/server.ts (buildTree) vs. src/filesystem/lib.ts (searchFilesWithValidation)
  • Protocol era / transport: Both 2026-07-28 and 2025-era over stdio / in-memory client transport

Reproduction / Root Cause in v2/main

  1. In directory_tree (src/filesystem/server.ts, lines 515–529), buildTree checks 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;
  1. In search_files (src/filesystem/lib.ts, lines 397–413), searchFilesWithValidation evaluates minimatch(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): relativePath is "node_modules". minimatch("node_modules", "node_modules") is true, so if (shouldExclude) continue skips recursing into top-level node_modules!
  • At packages/app/node_modules (nested directory): relativePath is "packages/app/node_modules". minimatch("packages/app/node_modules", "node_modules") is false! search_files enters packages/app/node_modules and 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_files still wastes I/O recursing unless the directory entry itself matches shouldExclude.

Expected vs. Actual Behavior

  • Expected: Passing excludePatterns: ["node_modules", ".git"] (or a bare filename/directory name without *) to search_files should behave identically to directory_tree: any nested directory or file matching pattern, **/${pattern}, or **/${pattern}/** is excluded and its directory subtree is not traversed.
  • Actual: directory_tree excludes nested packages/app/node_modules, while search_files traverses into packages/app/node_modules and 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`.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions