Search files
search_filesSearch for a literal or regex pattern across files in your dev environment, with control over case sensitivity, number of context lines, hidden files, and maximum results.
Instructions
Search for a pattern (literal or regex) across files inside the configured scope.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| pattern | Yes | ||
| path | No | . | |
| glob | No | ||
| regex | No | Treat `pattern` as a regular expression | |
| caseInsensitive | No | ||
| contextLines | No | ||
| maxResults | No | ||
| includeHidden | No |
Implementation Reference
- The main handler function for the 'search_files' tool. Resolves the search root, compiles the pattern matcher, and walks the directory tree calling scanFile on each file. Returns matches, truncated flag, and filesScanned count.
export async function searchFilesHandler( input: SearchFilesInput, config: McpDevtoolsConfig, ): Promise<ToolResult<SearchFilesOutput>> { const realRoot = await resolveWithinScope(config.scope, input.path); const stats = await stat(realRoot); if (!stats.isDirectory()) { throw new FileSystemError("Search path is not a directory", { path: input.path, code: "ENOTDIR", }); } const matcher = compilePattern(input); const globMatcher = input.glob !== undefined && input.glob.length > 0 ? picomatch(input.glob, { dot: input.includeHidden }) : null; const state: WalkState = { matches: [], filesScanned: 0, truncated: false, visited: new Set([realRoot]), }; await walk(realRoot, "", input, state, matcher, globMatcher); return ok({ matches: state.matches, truncated: state.truncated, filesScanned: state.filesScanned, }); } - Helper function compilePattern: converts the input pattern (literal or regex) into a RegExp with optional case-insensitivity.
function compilePattern(input: SearchFilesInput): RegExp { const flags = input.caseInsensitive ? "i" : ""; if (input.regex) { try { return new RegExp(input.pattern, flags); } catch (error) { throw new ValidationError(`Invalid regex: ${(error as Error).message}`, { pattern: input.pattern, }); } } const escaped = input.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return new RegExp(escaped, flags); } - walk function: recursively traverses directories, respecting hidden file filtering, ignore dirs (node_modules, .git, etc.), symlink cycle detection via visited set, and optional glob filtering. Calls scanFile on each matching file.
async function walk( absoluteDir: string, relativeDir: string, input: SearchFilesInput, state: WalkState, matcher: RegExp, globMatcher: ReturnType<typeof picomatch> | null, ): Promise<void> { if (state.truncated) { return; } let dir; try { dir = await opendir(absoluteDir); } catch (error) { throw mapFsError(error, absoluteDir); } try { for await (const entry of dir) { if (state.truncated) { return; } const name = entry.name; if (!input.includeHidden && pathIsHidden(name)) { continue; } const relPath = relativeDir === "" ? name : `${relativeDir}/${name}`; const absPath = path.join(absoluteDir, name); if (entry.isDirectory()) { if (DEFAULT_IGNORE_DIRS.has(name)) { continue; } let realChild: string; try { realChild = await realpath(absPath); } catch { continue; } if (state.visited.has(realChild)) { continue; } state.visited.add(realChild); await walk(realChild, relPath, input, state, matcher, globMatcher); continue; } if (!entry.isFile()) { continue; } if (globMatcher !== null && !globMatcher(relPath)) { continue; } await scanFile(absPath, relPath, input, state, matcher); } } finally { await dir.close().catch(() => undefined); } } - scanFile function: reads a file line-by-line, skips binary files and oversized files (>10MB). For each matching line, captures context lines (before and after) and collects SearchMatch results. Stops early when maxResults is reached.
async function scanFile( absolutePath: string, relativePath: string, input: SearchFilesInput, state: WalkState, matcher: RegExp, ): Promise<void> { let stats; try { stats = await stat(absolutePath); } catch { return; } if (stats.size > MAX_FILE_SIZE_FOR_SEARCH) { return; } if (await isBinary(absolutePath)) { return; } state.filesScanned += 1; const buffer: string[] = []; const stream = createReadStream(absolutePath, { encoding: "utf-8" }); const rl = readline.createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY }); let lineNo = 0; const pendingAfter: { match: SearchMatch; remaining: number }[] = []; try { for await (const rawLine of rl) { lineNo += 1; const line = rawLine.length > MAX_LINE_LENGTH ? `${rawLine.slice(0, MAX_LINE_LENGTH)}…` : rawLine; for (const pending of pendingAfter) { pending.match.after.push(line); pending.remaining -= 1; } while (pendingAfter.length > 0 && pendingAfter[0]!.remaining <= 0) { pendingAfter.shift(); } if (matcher.test(line)) { const before = buffer.slice(-input.contextLines); const match: SearchMatch = { file: relativePath, line: lineNo, match: line, before, after: [], }; state.matches.push(match); if (input.contextLines > 0) { pendingAfter.push({ match, remaining: input.contextLines }); } if (state.matches.length >= input.maxResults) { state.truncated = true; return; } } buffer.push(line); if (buffer.length > input.contextLines) { buffer.shift(); } } } finally { rl.close(); stream.destroy(); } } - Zod schema (SearchFilesInput) defining the input: pattern (string), path (default '.'), optional glob, regex flag, caseInsensitive, contextLines (0-20), maxResults (1-1000), includeHidden.
export const SearchFilesInput = z.object({ pattern: z.string().min(1), path: z.string().default("."), glob: z.string().optional(), regex: z.boolean().default(false).describe("Treat `pattern` as a regular expression"), caseInsensitive: z.boolean().default(false), contextLines: z.number().int().min(0).max(20).default(2), maxResults: z.number().int().positive().max(1000).default(100), includeHidden: z.boolean().default(false), }); - src/tools/index.ts:76-83 (registration)Tool registration in the allTools array: defines the tool with name 'search_files', title 'Search files', description, inputSchema, and handler.
defineTool({ name: "search_files", title: "Search files", description: "Search for a pattern (literal or regex) across files inside the configured scope.", inputSchema: SearchFilesInput, handler: searchFilesHandler, }), - src/tools/filesystem/_utils.ts:22-24 (helper)pathIsHidden helper used by walk to skip dotfiles unless includeHidden is true.
export function pathIsHidden(name: string): boolean { return name.length > 1 && name.startsWith("."); } - resolveWithinScope helper used to verify the search path is within the configured scope, following symlinks.
export async function resolveWithinScope( scopeRoot: string, relativeOrAbsolute: string, ): Promise<string> { const candidate = assertWithinScope(scopeRoot, relativeOrAbsolute); // Resolve the scope itself through symlinks so the post-realpath comparison // works on platforms where the temp dir or workspace lives behind a symlink // (e.g. macOS `/var` -> `/private/var`). const realScope = await safeRealpath(path.resolve(scopeRoot)); try { const real = await realpath(candidate); if (!isWithinPath(realScope, real)) { throw new ScopeViolationError(`Symlink target escapes scope: ${relativeOrAbsolute}`, { scope: realScope, target: real, }); } return real; } catch (error) { if (error instanceof ScopeViolationError) { throw error; } const errno = error as NodeJS.ErrnoException; if (errno.code === "ENOENT") { return candidate; } if (errno.code === "ELOOP") { throw new FileSystemError(`Symlink loop detected: ${relativeOrAbsolute}`, { path: candidate, cause: errno.code, }); } throw new FileSystemError(`Failed to resolve path: ${relativeOrAbsolute}`, { path: candidate, cause: errno.code ?? "UNKNOWN", }); } }