UNPKG

obsidian-mcp-server

Version:

MCP server for Obsidian vaults — read, write, search, and surgically edit notes, tags, and frontmatter via the Local REST API plugin. STDIO or Streamable HTTP.

200 lines 9.71 kB
/** * @fileoverview obsidian_replace_in_note — string/regex search-replace inside a * single note. Composed read → mutate → write at the service layer; replacements * are applied sequentially over the evolving body. * @module mcp-server/tools/definitions/obsidian-replace-in-note.tool */ import { tool, z } from '@cyanheads/mcp-ts-core'; import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors'; import { getObsidianService } from '../../../services/obsidian/obsidian-service.js'; import { TargetSchema } from './_shared/schemas.js'; const ReplacementSchema = z .object({ search: z.string().min(1).describe('Substring or regex pattern to match.'), replace: z.string().describe('Replacement text. Empty string deletes matches.'), useRegex: z.boolean().default(false).describe('Treat `search` as an ECMAScript regex pattern.'), caseSensitive: z.boolean().default(true).describe('When false, match case-insensitively.'), wholeWord: z .boolean() .default(false) .describe('Match only at word boundaries. Applies in both literal and regex modes — the search pattern is wrapped with `\\b…\\b`.'), flexibleWhitespace: z .boolean() .default(false) .describe('Treat any run of whitespace in `search` as matching any whitespace in the body. Literal mode only — has no effect when `useRegex: true` (express it directly with `\\s+`).'), replaceAll: z.boolean().default(true).describe('When false, only the first match is replaced.'), }) .describe('A single search/replace operation.'); export const obsidianReplaceInNote = tool('obsidian_replace_in_note', { description: "Search and replace inside a single note, literally or by regex. Replacements run in array order, each over the previous one's output. Use for edits that don't fit `obsidian_patch_note`'s structural targets — e.g., body-wide find-and-replace.", annotations: { destructiveHint: true }, input: z.object({ target: TargetSchema.describe('Where the note lives.'), replacements: z .array(ReplacementSchema) .min(1) .describe('Replacements to apply in array order over the evolving content.'), }), output: z.object({ path: z.string().describe('Resolved vault-relative path of the note.'), totalReplacements: z .number() .describe('Total number of substitutions applied across all replacement entries.'), perReplacement: z .array(z .object({ search: z.string().describe('The search term/pattern that ran.'), count: z.number().describe('Number of matches replaced for this entry.'), }) .describe('Counts for one replacement entry.')) .describe('Per-entry counts in the order replacements were applied.'), previousSizeInBytes: z .number() .describe('Byte size of the note before replacements were applied.'), currentSizeInBytes: z .number() .describe('Byte size of the note after replacements were applied. Equals `previousSizeInBytes` when no matches were found and no write was issued.'), }), auth: ['tool:obsidian_replace_in_note:write'], errors: [ { reason: 'path_forbidden', code: JsonRpcErrorCode.Forbidden, when: 'The target path is outside OBSIDIAN_WRITE_PATHS, or OBSIDIAN_READ_ONLY=true denies all writes. (The pre-read also requires the path to be readable.)', recovery: 'Use a path inside the configured write scope. The error data echoes the active scope.', }, { reason: 'regex_invalid', code: JsonRpcErrorCode.ValidationError, when: 'A `useRegex: true` replacement supplied a `search` pattern that is not a valid ECMAScript regex.', recovery: 'Use a valid ECMAScript regex, or set useRegex to false to match `search` as a literal string.', }, { reason: 'note_missing', code: JsonRpcErrorCode.NotFound, when: 'The vault path does not resolve to an existing note.', recovery: 'Verify the path with obsidian_list_notes or use obsidian_search_notes to locate the note.', }, { reason: 'no_active_file', code: JsonRpcErrorCode.NotFound, when: 'Target was `active` but no file is currently open in Obsidian.', recovery: 'Call obsidian_open_in_ui to focus a file, or pass an explicit path target instead.', }, { reason: 'periodic_not_found', code: JsonRpcErrorCode.NotFound, when: 'Target was `periodic` but no matching periodic note exists.', recovery: 'Create the periodic note first or pass an explicit path target.', }, { reason: 'periodic_disabled', code: JsonRpcErrorCode.ValidationError, when: "Target was `periodic` but the requested period is not enabled in Obsidian's Periodic Notes plugin settings.", recovery: "Pass an explicit path target — the requested period is disabled in the operator's Periodic Notes plugin.", }, ], async handler(input, ctx) { const svc = getObsidianService(); const { target } = input; const note = await svc.getNoteJson(ctx, target); // Delivered bytes — not note.stat.size (see ObsidianService.tryGetSize). const previousSizeInBytes = Buffer.byteLength(note.content, 'utf8'); let body = note.content; const perReplacement = []; let totalReplacements = 0; for (const r of input.replacements) { let count = 0; if (r.useRegex) { const pattern = r.wholeWord ? `\\b(?:${r.search})\\b` : r.search; let re; try { re = new RegExp(pattern, `${r.replaceAll ? 'g' : ''}${r.caseSensitive ? '' : 'i'}`); } catch (err) { throw ctx.fail('regex_invalid', `Invalid regex '${r.search}': ${err.message}`, { search: r.search, ...ctx.recoveryFor('regex_invalid') }, { cause: err }); } // Count separately, then apply with the string overload so $1/$2/$& // capture-group references in `r.replace` are honored. const matches = body.match(re); count = matches ? (re.global ? matches.length : 1) : 0; body = body.replace(re, r.replace); } else if (r.wholeWord || r.flexibleWhitespace) { // Literal-with-transformations: build a regex from the escaped needle. // Use the callback overload so `$1`/`$&` in `r.replace` stay literal. let escaped = r.search.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); if (r.flexibleWhitespace) escaped = escaped.replace(/\s+/g, '\\s+'); if (r.wholeWord) escaped = `\\b${escaped}\\b`; const re = new RegExp(escaped, `${r.replaceAll ? 'g' : ''}${r.caseSensitive ? '' : 'i'}`); body = body.replace(re, () => { count++; return r.replace; }); } else if (r.caseSensitive) { body = replaceLiteral(body, r.search, r.replace, r.replaceAll, () => { count++; }); } else { body = replaceLiteralCaseInsensitive(body, r.search, r.replace, r.replaceAll, () => { count++; }); } perReplacement.push({ search: r.search, count }); totalReplacements += count; } let currentSizeInBytes = previousSizeInBytes; if (totalReplacements > 0) { await svc.writeNote(ctx, target, body, 'markdown'); currentSizeInBytes = await svc.getSize(ctx, { type: 'path', path: note.path }); } return { path: note.path, totalReplacements, perReplacement, previousSizeInBytes, currentSizeInBytes, }; }, format: (result) => { const lines = [ `**Replaced in ${result.path}**`, `*Total replacements:* ${result.totalReplacements}`, `*Size:* ${result.previousSizeInBytes}${result.currentSizeInBytes} bytes`, '', '**Per replacement**', ]; for (const r of result.perReplacement) { lines.push(`- \`${r.search}\` → ${r.count} match${r.count === 1 ? '' : 'es'}`); } return [{ type: 'text', text: lines.join('\n') }]; }, }); function replaceLiteral(haystack, needle, replacement, all, onMatch) { if (!all) { const idx = haystack.indexOf(needle); if (idx === -1) return haystack; onMatch(); return haystack.slice(0, idx) + replacement + haystack.slice(idx + needle.length); } const parts = haystack.split(needle); if (parts.length <= 1) return haystack; for (let i = 0; i < parts.length - 1; i++) onMatch(); return parts.join(replacement); } function replaceLiteralCaseInsensitive(haystack, needle, replacement, all, onMatch) { const escaped = needle.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); const re = new RegExp(escaped, all ? 'gi' : 'i'); return haystack.replace(re, () => { onMatch(); return replacement; }); } //# sourceMappingURL=obsidian-replace-in-note.tool.js.map