Replace in Note
replace_in_notePerform search-and-replace in a note using literal strings or regex patterns, with an option to confirm expected match count to prevent unintended over-replacement.
Instructions
Search-and-replace within a single note. Supports literal strings or regex patterns. With expectedCount, the operation refuses to commit unless that many matches are present, guarding against accidental over-replacement when an LLM drafts a pattern that's too broad. Returns the count of replacements made.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | Vault-relative path to the note. | |
| find | Yes | Literal string (default) or regex pattern to match. | |
| replace | Yes | Replacement text. With `regex: true`, supports $1, $2 backreferences. | |
| regex | No | Treat `find` as a JavaScript regex (multi-line, case-sensitive by default). | |
| flags | No | Regex flags (e.g., 'gi'). Defaults to 'g' so all matches are replaced. | |
| expectedCount | No | If set, abort unless exactly this many matches are found. |
Implementation Reference
- src/tools/sections.ts:198-233 (handler)The async handler function for the 'replace_in_note' tool. It uses updateNote to atomically read/modify/write the note file. If 'regex' is false, it escapes the find string and uses a global regex. If true, applies the provided flags (must include 'g'). Supports expectedCount guard to prevent accidental over-replacement. Returns the count of replacements made.
async ({ path: notePath, find, replace, regex, flags, expectedCount }) => { try { let count = 0; await updateNote(vaultPath, notePath, (existing) => { let pattern: RegExp; if (regex) { const f = flags ?? "g"; if (!f.includes("g")) { throw new Error("regex flags must include 'g' for replace_in_note"); } pattern = new RegExp(find, f); } else { const escaped = find.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); pattern = new RegExp(escaped, "g"); } const matches = existing.match(pattern); count = matches ? matches.length : 0; if (expectedCount !== undefined && count !== expectedCount) { throw new Error( `Match-count check failed: expected ${expectedCount}, found ${count}. No changes written.`, ); } if (count === 0) return existing; return existing.replace(pattern, replace); }); return textResult( count === 0 ? `No matches in ${notePath} — file unchanged.` : `Replaced ${count} match(es) in ${notePath}.`, ); } catch (err) { log.error("replace_in_note failed", { tool: "replace_in_note", err: err as Error }); return errorResult(`Error replacing in note: ${sanitizeError(err)}`); } }, ); - src/tools/sections.ts:189-196 (schema)Input schema for 'replace_in_note' using Zod. Defines parameters: path (string), find (string), replace (string), regex (boolean, default false), flags (optional string), expectedCount (optional integer).
inputSchema: { path: z.string().min(1).describe("Vault-relative path to the note."), find: z.string().min(1).describe("Literal string (default) or regex pattern to match."), replace: z.string().describe("Replacement text. With `regex: true`, supports $1, $2 backreferences."), regex: z.boolean().default(false).describe("Treat `find` as a JavaScript regex (multi-line, case-sensitive by default)."), flags: z.string().optional().describe("Regex flags (e.g., 'gi'). Defaults to 'g' so all matches are replaced."), expectedCount: z.number().int().min(0).optional().describe("If set, abort unless exactly this many matches are found."), }, - src/tools/sections.ts:177-233 (registration)Registration of the 'replace_in_note' tool on the MCP server via server.registerTool() call inside registerSectionTools().
server.registerTool( "replace_in_note", { title: "Replace in Note", description: "Search-and-replace within a single note. Supports literal strings or regex patterns. With `expectedCount`, the operation refuses to commit unless that many matches are present, guarding against accidental over-replacement when an LLM drafts a pattern that's too broad. Returns the count of replacements made.", annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, }, inputSchema: { path: z.string().min(1).describe("Vault-relative path to the note."), find: z.string().min(1).describe("Literal string (default) or regex pattern to match."), replace: z.string().describe("Replacement text. With `regex: true`, supports $1, $2 backreferences."), regex: z.boolean().default(false).describe("Treat `find` as a JavaScript regex (multi-line, case-sensitive by default)."), flags: z.string().optional().describe("Regex flags (e.g., 'gi'). Defaults to 'g' so all matches are replaced."), expectedCount: z.number().int().min(0).optional().describe("If set, abort unless exactly this many matches are found."), }, }, async ({ path: notePath, find, replace, regex, flags, expectedCount }) => { try { let count = 0; await updateNote(vaultPath, notePath, (existing) => { let pattern: RegExp; if (regex) { const f = flags ?? "g"; if (!f.includes("g")) { throw new Error("regex flags must include 'g' for replace_in_note"); } pattern = new RegExp(find, f); } else { const escaped = find.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); pattern = new RegExp(escaped, "g"); } const matches = existing.match(pattern); count = matches ? matches.length : 0; if (expectedCount !== undefined && count !== expectedCount) { throw new Error( `Match-count check failed: expected ${expectedCount}, found ${count}. No changes written.`, ); } if (count === 0) return existing; return existing.replace(pattern, replace); }); return textResult( count === 0 ? `No matches in ${notePath} — file unchanged.` : `Replaced ${count} match(es) in ${notePath}.`, ); } catch (err) { log.error("replace_in_note failed", { tool: "replace_in_note", err: err as Error }); return errorResult(`Error replacing in note: ${sanitizeError(err)}`); } }, ); - src/lib/vault.ts:410-420 (helper)The updateNote helper used by replace_in_note. Provides atomic read-modify-write with per-file locking. Skips writes when content is unchanged (e.g., zero matches) to avoid mtime bumps.
* Atomic read-modify-write: reads existing content, applies `transform`, and * writes the result while holding the per-file lock for the full sequence. * Prevents lost updates when concurrent callers would otherwise read the same * base and overwrite each other's changes. * * Skips the write when the transform returns the existing content unchanged. * Without this guard, no-op tools (e.g. `replace_in_note` with zero matches, * `rename_tag` on a note that contains no occurrences) would still call * `atomicWriteFile`, bumping mtime and invalidating downstream caches * (index-cache, embedding-store) for files we didn't actually modify. */