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
JavaScript
/**
* @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