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

  1. xubenqiang commented on Oct 27, 2026

    @xubenqiang

    I hit this while comparing the two tools and have a fix ready, but this repository's PR creation policy is collaborators_only, so CreatePullRequest is refused for me. Leaving the work here instead of a PR in case it is useful.

    The fix extracts the predicate directory_tree already uses into isExcludedPath() in src/filesystem/lib.ts and has both searchFilesWithValidation and buildTree call 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 before validatePath, 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: new search_files case with excludePatterns: ["deeper"] over the existing sub/deeper/low.txt fixture returns sub/mid.txt + top.txt (old code returned sub/deeper/low.txt), plus unit tests for isExcludedPath covering 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). tsc and eslint clean; commit is DCO-signed.

  2. added
    bugSomething isn't working
    server-filesystemReference implementation for the Filesystem MCP server - src/filesystem
    on Oct 28, 2026
  3. cliffhall commented on Oct 28, 2026

    @cliffhall
    Member

    Triage: Priority Low (total 5)

    • Severity 2: search_files still descends into a directory named by a bare excludePatterns entry (such as node_modules), unlike directory_tree; the workaround is a glob like **/node_modules/**. Confirmed on v2/main: search_files matches each pattern with plain minimatch, without the **/ expansion directory_tree applies.
    • Urgency 2: wanted eventually, nothing blocked on it
    • Bonuses: +1 bug label

    Board: Servers V2 (#43), Status Incoming, awaiting maintainer review (no milestone yet).

  4. Pranav-Nexus commented on Oct 29, 2026

    @Pranav-Nexus
    Author

    Thanks for triaging @cliffhall! One quick note on the **/node_modules/** workaround and how it interacts with the #4162 subtree-pruning guarantee in searchFilesWithValidation (src/filesystem/lib.ts):

    When a caller passes excludePatterns: ["**/node_modules/**"] (with trailing /**):

    1. At the directory entry itself (relativePath = "node_modules" or "packages/app/node_modules"), minimatch(relativePath, "**/node_modules/**", { dot: true }) evaluates to false.
    2. Because shouldExclude is false for the directory entry:
      • search_files still counts visit(), runs await validatePath(fullPath, allowedDirectories) (realpathWithTimeout), and recurses into await search(fullPath) (readdirWithTimeout) on the excluded directory before its children are filtered on the next level — defeating the #4162 guarantee that an excluded slow/cloud-storage folder is never realpath'd or readdir'd.
      • If pattern matches the directory name (e.g. pattern: "**/*" or "**/node_modules"), the directory path itself is still returned in results.

    To actually prune the directory before validatePath/readdir on v2/main today, the caller has to pass **/node_modules (or both ["**/node_modules", "**/node_modules/**"]). Since the **/node_modules/** workaround is partial and still touches excluded directories, this might warrant Severity 3 (Total 6 → Medium) under the triage rubric.

  5. added this to the v2.4.0 milestone on Oct 29, 2026
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

    bugSomething isn't workingserver-filesystemReference implementation for the Filesystem MCP server - src/filesystemv2

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions