search_and_replace
Perform bulk search-and-replace operations across multiple files using glob patterns. Preview changes with dry runs, support literal or regex matching, and apply replacements to all occurrences in each file.
Instructions
Bulk search-and-replace across files matching a glob. Replaces ALL occurrences per file (unlike edit: first only). Always dryRun:true first — returns a unified diff. Literal matching by default; isRegex:true enables RE2 with capture groups ($1, $2).
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| path | No | Base directory (default: root). Absolute path required if multiple roots. | |
| filePattern | No | Glob to filter files. Default: **/* | **/* |
| searchPattern | Yes | Text to search for. Literal by default; RE2 regex when `isRegex=true`. | |
| replacement | Yes | Replacement text | |
| isRegex | No | Treat searchPattern as RE2 regex. Supports capture groups ($1, $2) in replacement. | |
| caseSensitive | No | Case-sensitive matching. Default: true. | |
| dryRun | No | Preview matches without writing. Check changedFiles and matches in the response before committing. | |
| includeHidden | No | Include hidden files/directories (starting with .). Default: false. | |
| includeIgnored | No | Include .gitignore-ignored files (node_modules, dist). Default: false. | |
| returnDiff | No | Return unified diff of changes even if dryRun is false. Default: false. | |
| maxFiles | No | Max files to process before stopping |
Implementation Reference
- src/tools/replace-in-files.ts:454-520 (handler)The main handler function that executes the bulk search-and-replace operation across files.
async function handleSearchAndReplace( args: SearchAndReplaceArgs, signal?: AbortSignal, onProgress: (progress: { total?: number; current: number }) => void = () => {} ): Promise<ToolResponse<SearchAndReplaceOutput>> { const maxFileSize = MAX_TEXT_FILE_SIZE; const root = await resolveSearchRoot(args.path, signal); const matcher = createReplacementMatcher(args); const entries = globEntries({ cwd: root, pattern: args.filePattern, excludePatterns: args.includeIgnored ? [] : DEFAULT_EXCLUDE_PATTERNS, includeHidden: args.includeHidden ?? false, baseNameMatch: false, caseSensitiveMatch: true, // Default to sensitive for file paths followSymbolicLinks: false, onlyFiles: true, stats: false, suppressErrors: true, }); const summary = createReplaceSummary(root); const t0 = performance.now(); const context: ReplaceContext = { options: { dryRun: args.dryRun, returnDiff: args.returnDiff ?? false, }, replacement: args.replacement, matcher, maxFileSize, signal, summary, }; const { stoppedByLimit } = await processEntriesConcurrently(entries, { signal, concurrency: REPLACE_CONCURRENCY, ...(args.maxFiles !== undefined ? { maxEntries: args.maxFiles } : {}), onEntry: () => { summary.processedFiles++; reportPeriodicProgress(onProgress, summary.processedFiles, { throttleModulo: 25, }); }, runEntry: (entryPath) => processEntry(entryPath, context), }); summary.perfTimeMs = performance.now() - t0; if (stoppedByLimit) { summary.stoppedReason = 'maxFiles'; } reportPeriodicProgress(onProgress, summary.processedFiles, { throttleModulo: 25, force: true, }); return buildToolResponse( buildSearchAndReplaceText(summary, args.dryRun), buildSearchAndReplaceStructuredResult(summary, args) ); } - src/tools/replace-in-files.ts:522-614 (registration)Registration function for the 'search_and_replace' tool.
export function registerSearchAndReplaceTool( server: McpServer, options: ToolRegistrationOptions = {} ): void { const handler = ( args: z.infer<typeof SearchAndReplaceInputSchema>, extra: ToolExtra ): Promise<ToolResult<z.infer<typeof SearchAndReplaceOutputSchema>>> => executeToolWithDiagnostics({ toolName: 'search_and_replace', extra, outputSchema: SearchAndReplaceOutputSchema, timedSignal: {}, ...(args.path ? { context: { path: args.path } } : {}), run: async (signal) => { const dryLabel = args.dryRun ? ' [dry run]' : ''; const truncatedPattern = truncateProgressPattern(args.searchPattern); const context = `"${truncatedPattern}" in ${args.filePattern}${dryLabel}`; const progress = createToolProgressSession( extra, `🛠 replace: ${context}` ); const progressWithMessage = ({ current, total, }: { total?: number; current: number; }): void => { progress.update({ current, ...(total !== undefined ? { total } : {}), message: `🛠 replace: ${truncatedPattern} [${current} files]`, }); }; try { const result = await handleSearchAndReplace( args, signal, progressWithMessage ); const sc = result.structuredContent; const finalCurrent = resolveFinalProgressCurrent( progress, (sc.processedFiles ?? 0) + 1 ); const matchWord = (sc.matches ?? 0) === 1 ? 'match' : 'matches'; const fileWord = (sc.filesChanged ?? 0) === 1 ? 'file' : 'files'; let endSuffix = `${sc.matches ?? 0} ${matchWord} in ${sc.filesChanged ?? 0} ${fileWord}`; if (sc.failedFiles) endSuffix += `, ${sc.failedFiles} failed`; progress.complete( `🛠 replace: ${context} • ${endSuffix}`, finalCurrent ); return result; } catch (error) { progress.fail(`🛠 replace: ${context} • failed`); throw error; } }, onError: (error) => buildToolErrorResponse(error, ErrorCode.E_UNKNOWN, args.path), }); const { isInitialized } = options; const wrappedHandler = wrapToolHandler(handler, { guard: isInitialized, }); const validatedHandler = withValidatedArgs( SearchAndReplaceInputSchema, wrappedHandler ); if ( registerToolTaskIfAvailable( server, 'search_and_replace', SEARCH_AND_REPLACE_TOOL, validatedHandler, options.iconInfo, isInitialized ) ) return; server.registerTool( 'search_and_replace', withDefaultIcons({ ...SEARCH_AND_REPLACE_TOOL }, options.iconInfo), validatedHandler ); } - src/schemas.ts:844-904 (schema)Zod schema definitions for the 'search_and_replace' tool input and output.
export const SearchAndReplaceInputSchema = z.strictObject({ path: OptionalPathSchema.describe(DESC_PATH_ROOT), filePattern: z .string() .min(1, 'Pattern required') .max(1000, 'Max 1000 chars') .optional() .default('**/*') .refine((val) => isSafeGlobPattern(val), { error: 'Invalid glob or unsafe path (absolute/.. forbidden)', }) .describe('Glob to filter files. Default: **/*'), searchPattern: z .string() .min(1, 'Search pattern required') .max(1000, 'Max 1000 chars') .describe( 'Text to search for. Literal by default; RE2 regex when `isRegex=true`.' ), replacement: z.string().describe('Replacement text'), isRegex: defaultFalseBoolean( 'Treat searchPattern as RE2 regex. Supports capture groups ($1, $2) in replacement.' ), caseSensitive: z .boolean() .optional() .default(true) .describe('Case-sensitive matching. Default: true.'), dryRun: defaultFalseBoolean( 'Preview matches without writing. Check changedFiles and matches in the response before committing.' ), includeHidden: z .boolean() .optional() .describe( 'Include hidden files/directories (starting with .). Default: false.' ), includeIgnored: z .boolean() .optional() .describe( 'Include .gitignore-ignored files (node_modules, dist). Default: false.' ), returnDiff: z .boolean() .optional() .describe( 'Return unified diff of changes even if dryRun is false. Default: false.' ), maxFiles: z .int({ error: 'Must be integer' }) .min(1, 'Min: 1') .max(10000, 'Max: 10,000') .optional() .describe('Max files to process before stopping'), }); export const SearchAndReplaceOutputSchema = z.strictObject({ ok: SuccessFlagSchema, matches: NonNegativeIntegerSchema.optional().describe('Total matches found'), filesChanged: NonNegativeIntegerSchema.optional().describe('Files modified'),