obsidian-mcp-server 2.0.6 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +364 -0
- package/Dockerfile +99 -0
- package/LICENSE +4 -6
- package/README.md +246 -206
- package/changelog/3.0.x/3.0.0.md +102 -0
- package/changelog/template.md +51 -0
- package/dist/config/server-config.d.ts +19 -0
- package/dist/config/server-config.d.ts.map +1 -0
- package/dist/config/server-config.js +55 -0
- package/dist/config/server-config.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -295
- package/dist/index.js.map +1 -0
- package/dist/mcp-server/prompts/definitions/index.d.ts +8 -0
- package/dist/mcp-server/prompts/definitions/index.d.ts.map +1 -0
- package/dist/mcp-server/prompts/definitions/index.js +8 -0
- package/dist/mcp-server/prompts/definitions/index.js.map +1 -0
- package/dist/mcp-server/resources/definitions/index.d.ts +36 -0
- package/dist/mcp-server/resources/definitions/index.d.ts.map +1 -0
- package/dist/mcp-server/resources/definitions/index.js +9 -0
- package/dist/mcp-server/resources/definitions/index.js.map +1 -0
- package/dist/mcp-server/resources/definitions/obsidian-status.resource.d.ts +23 -0
- package/dist/mcp-server/resources/definitions/obsidian-status.resource.d.ts.map +1 -0
- package/dist/mcp-server/resources/definitions/obsidian-status.resource.js +47 -0
- package/dist/mcp-server/resources/definitions/obsidian-status.resource.js.map +1 -0
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts +13 -0
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts.map +1 -0
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js +30 -0
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js.map +1 -0
- package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.d.ts +21 -0
- package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.d.ts.map +1 -0
- package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js +38 -0
- package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js.map +1 -0
- package/dist/mcp-server/tools/definitions/_shared/schemas.d.ts +45 -0
- package/dist/mcp-server/tools/definitions/_shared/schemas.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/_shared/schemas.js +66 -0
- package/dist/mcp-server/tools/definitions/_shared/schemas.js.map +1 -0
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +51 -0
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +120 -0
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -0
- package/dist/mcp-server/tools/definitions/index.d.ts +531 -0
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/index.js +38 -0
- package/dist/mcp-server/tools/definitions/index.js.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +42 -0
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +58 -0
- package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +46 -0
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +66 -0
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.d.ts +19 -0
- package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.js +43 -0
- package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +92 -0
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +245 -0
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.d.ts +13 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.js +38 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +59 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +273 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts +13 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +38 -0
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts +68 -0
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +178 -0
- package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts +77 -0
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +172 -0
- package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +22 -0
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +88 -0
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +81 -0
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +83 -0
- package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts +59 -0
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +167 -0
- package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +76 -0
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +244 -0
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +41 -0
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +76 -0
- package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -0
- package/dist/services/obsidian/frontmatter-ops.d.ts +34 -0
- package/dist/services/obsidian/frontmatter-ops.d.ts.map +1 -0
- package/dist/services/obsidian/frontmatter-ops.js +230 -0
- package/dist/services/obsidian/frontmatter-ops.js.map +1 -0
- package/dist/services/obsidian/obsidian-service.d.ts +73 -0
- package/dist/services/obsidian/obsidian-service.d.ts.map +1 -0
- package/dist/services/obsidian/obsidian-service.js +399 -0
- package/dist/services/obsidian/obsidian-service.js.map +1 -0
- package/dist/services/obsidian/section-extractor.d.ts +13 -0
- package/dist/services/obsidian/section-extractor.d.ts.map +1 -0
- package/dist/services/obsidian/section-extractor.js +124 -0
- package/dist/services/obsidian/section-extractor.js.map +1 -0
- package/dist/services/obsidian/types.d.ts +91 -0
- package/dist/services/obsidian/types.d.ts.map +1 -0
- package/dist/services/obsidian/types.js +7 -0
- package/dist/services/obsidian/types.js.map +1 -0
- package/package.json +63 -69
- package/server.json +167 -0
- package/CHANGELOG.md +0 -118
- package/dist/config/index.d.ts +0 -41
- package/dist/config/index.js +0 -191
- package/dist/mcp-server/server.d.ts +0 -33
- package/dist/mcp-server/server.js +0 -211
- package/dist/mcp-server/tools/obsidianDeleteNoteTool/index.d.ts +0 -12
- package/dist/mcp-server/tools/obsidianDeleteNoteTool/index.js +0 -12
- package/dist/mcp-server/tools/obsidianDeleteNoteTool/logic.d.ts +0 -51
- package/dist/mcp-server/tools/obsidianDeleteNoteTool/logic.js +0 -168
- package/dist/mcp-server/tools/obsidianDeleteNoteTool/registration.d.ts +0 -19
- package/dist/mcp-server/tools/obsidianDeleteNoteTool/registration.js +0 -91
- package/dist/mcp-server/tools/obsidianGlobalSearchTool/index.d.ts +0 -12
- package/dist/mcp-server/tools/obsidianGlobalSearchTool/index.js +0 -12
- package/dist/mcp-server/tools/obsidianGlobalSearchTool/logic.d.ts +0 -77
- package/dist/mcp-server/tools/obsidianGlobalSearchTool/logic.js +0 -341
- package/dist/mcp-server/tools/obsidianGlobalSearchTool/registration.d.ts +0 -18
- package/dist/mcp-server/tools/obsidianGlobalSearchTool/registration.js +0 -69
- package/dist/mcp-server/tools/obsidianListNotesTool/index.d.ts +0 -12
- package/dist/mcp-server/tools/obsidianListNotesTool/index.js +0 -12
- package/dist/mcp-server/tools/obsidianListNotesTool/logic.d.ts +0 -68
- package/dist/mcp-server/tools/obsidianListNotesTool/logic.js +0 -215
- package/dist/mcp-server/tools/obsidianListNotesTool/registration.d.ts +0 -23
- package/dist/mcp-server/tools/obsidianListNotesTool/registration.js +0 -98
- package/dist/mcp-server/tools/obsidianManageFrontmatterTool/index.d.ts +0 -3
- package/dist/mcp-server/tools/obsidianManageFrontmatterTool/index.js +0 -2
- package/dist/mcp-server/tools/obsidianManageFrontmatterTool/logic.d.ts +0 -42
- package/dist/mcp-server/tools/obsidianManageFrontmatterTool/logic.js +0 -152
- package/dist/mcp-server/tools/obsidianManageFrontmatterTool/registration.d.ts +0 -3
- package/dist/mcp-server/tools/obsidianManageFrontmatterTool/registration.js +0 -52
- package/dist/mcp-server/tools/obsidianManageTagsTool/index.d.ts +0 -3
- package/dist/mcp-server/tools/obsidianManageTagsTool/index.js +0 -2
- package/dist/mcp-server/tools/obsidianManageTagsTool/logic.d.ts +0 -28
- package/dist/mcp-server/tools/obsidianManageTagsTool/logic.js +0 -161
- package/dist/mcp-server/tools/obsidianManageTagsTool/registration.d.ts +0 -3
- package/dist/mcp-server/tools/obsidianManageTagsTool/registration.js +0 -52
- package/dist/mcp-server/tools/obsidianReadNoteTool/index.d.ts +0 -12
- package/dist/mcp-server/tools/obsidianReadNoteTool/index.js +0 -12
- package/dist/mcp-server/tools/obsidianReadNoteTool/logic.d.ts +0 -87
- package/dist/mcp-server/tools/obsidianReadNoteTool/logic.js +0 -216
- package/dist/mcp-server/tools/obsidianReadNoteTool/registration.d.ts +0 -20
- package/dist/mcp-server/tools/obsidianReadNoteTool/registration.js +0 -101
- package/dist/mcp-server/tools/obsidianSearchReplaceTool/index.d.ts +0 -12
- package/dist/mcp-server/tools/obsidianSearchReplaceTool/index.js +0 -12
- package/dist/mcp-server/tools/obsidianSearchReplaceTool/logic.d.ts +0 -255
- package/dist/mcp-server/tools/obsidianSearchReplaceTool/logic.js +0 -583
- package/dist/mcp-server/tools/obsidianSearchReplaceTool/registration.d.ts +0 -22
- package/dist/mcp-server/tools/obsidianSearchReplaceTool/registration.js +0 -111
- package/dist/mcp-server/tools/obsidianUpdateNoteTool/index.d.ts +0 -12
- package/dist/mcp-server/tools/obsidianUpdateNoteTool/index.js +0 -12
- package/dist/mcp-server/tools/obsidianUpdateNoteTool/logic.d.ts +0 -183
- package/dist/mcp-server/tools/obsidianUpdateNoteTool/logic.js +0 -490
- package/dist/mcp-server/tools/obsidianUpdateNoteTool/registration.d.ts +0 -21
- package/dist/mcp-server/tools/obsidianUpdateNoteTool/registration.js +0 -108
- package/dist/mcp-server/transports/auth/core/authContext.d.ts +0 -33
- package/dist/mcp-server/transports/auth/core/authContext.js +0 -24
- package/dist/mcp-server/transports/auth/core/authTypes.d.ts +0 -17
- package/dist/mcp-server/transports/auth/core/authTypes.js +0 -5
- package/dist/mcp-server/transports/auth/core/authUtils.d.ts +0 -18
- package/dist/mcp-server/transports/auth/core/authUtils.js +0 -45
- package/dist/mcp-server/transports/auth/index.d.ts +0 -10
- package/dist/mcp-server/transports/auth/index.js +0 -9
- package/dist/mcp-server/transports/auth/strategies/jwt/jwtMiddleware.d.ts +0 -27
- package/dist/mcp-server/transports/auth/strategies/jwt/jwtMiddleware.js +0 -149
- package/dist/mcp-server/transports/auth/strategies/oauth/oauthMiddleware.d.ts +0 -20
- package/dist/mcp-server/transports/auth/strategies/oauth/oauthMiddleware.js +0 -124
- package/dist/mcp-server/transports/httpErrorHandler.d.ts +0 -26
- package/dist/mcp-server/transports/httpErrorHandler.js +0 -73
- package/dist/mcp-server/transports/httpTransport.d.ts +0 -21
- package/dist/mcp-server/transports/httpTransport.js +0 -208
- package/dist/mcp-server/transports/stdioTransport.d.ts +0 -42
- package/dist/mcp-server/transports/stdioTransport.js +0 -63
- package/dist/services/obsidianRestAPI/index.d.ts +0 -15
- package/dist/services/obsidianRestAPI/index.js +0 -17
- package/dist/services/obsidianRestAPI/methods/activeFileMethods.d.ts +0 -38
- package/dist/services/obsidianRestAPI/methods/activeFileMethods.js +0 -62
- package/dist/services/obsidianRestAPI/methods/commandMethods.d.ts +0 -22
- package/dist/services/obsidianRestAPI/methods/commandMethods.js +0 -31
- package/dist/services/obsidianRestAPI/methods/openMethods.d.ts +0 -16
- package/dist/services/obsidianRestAPI/methods/openMethods.js +0 -21
- package/dist/services/obsidianRestAPI/methods/patchMethods.d.ts +0 -37
- package/dist/services/obsidianRestAPI/methods/patchMethods.js +0 -94
- package/dist/services/obsidianRestAPI/methods/periodicNoteMethods.d.ts +0 -42
- package/dist/services/obsidianRestAPI/methods/periodicNoteMethods.js +0 -66
- package/dist/services/obsidianRestAPI/methods/searchMethods.d.ts +0 -25
- package/dist/services/obsidianRestAPI/methods/searchMethods.js +0 -36
- package/dist/services/obsidianRestAPI/methods/vaultMethods.d.ts +0 -58
- package/dist/services/obsidianRestAPI/methods/vaultMethods.js +0 -144
- package/dist/services/obsidianRestAPI/service.d.ts +0 -195
- package/dist/services/obsidianRestAPI/service.js +0 -379
- package/dist/services/obsidianRestAPI/types.d.ts +0 -127
- package/dist/services/obsidianRestAPI/types.js +0 -7
- package/dist/services/obsidianRestAPI/vaultCache/index.d.ts +0 -4
- package/dist/services/obsidianRestAPI/vaultCache/index.js +0 -4
- package/dist/services/obsidianRestAPI/vaultCache/service.d.ts +0 -88
- package/dist/services/obsidianRestAPI/vaultCache/service.js +0 -299
- package/dist/types-global/errors.d.ts +0 -73
- package/dist/types-global/errors.js +0 -71
- package/dist/utils/index.d.ts +0 -5
- package/dist/utils/index.js +0 -13
- package/dist/utils/internal/asyncUtils.d.ts +0 -54
- package/dist/utils/internal/asyncUtils.js +0 -101
- package/dist/utils/internal/errorHandler.d.ts +0 -176
- package/dist/utils/internal/errorHandler.js +0 -351
- package/dist/utils/internal/index.d.ts +0 -4
- package/dist/utils/internal/index.js +0 -4
- package/dist/utils/internal/logger.d.ts +0 -141
- package/dist/utils/internal/logger.js +0 -406
- package/dist/utils/internal/requestContext.d.ts +0 -83
- package/dist/utils/internal/requestContext.js +0 -72
- package/dist/utils/metrics/index.d.ts +0 -1
- package/dist/utils/metrics/index.js +0 -1
- package/dist/utils/metrics/tokenCounter.d.ts +0 -27
- package/dist/utils/metrics/tokenCounter.js +0 -128
- package/dist/utils/obsidian/index.d.ts +0 -5
- package/dist/utils/obsidian/index.js +0 -5
- package/dist/utils/obsidian/obsidianApiUtils.d.ts +0 -14
- package/dist/utils/obsidian/obsidianApiUtils.js +0 -29
- package/dist/utils/obsidian/obsidianStatUtils.d.ts +0 -68
- package/dist/utils/obsidian/obsidianStatUtils.js +0 -143
- package/dist/utils/parsing/dateParser.d.ts +0 -56
- package/dist/utils/parsing/dateParser.js +0 -104
- package/dist/utils/parsing/index.d.ts +0 -2
- package/dist/utils/parsing/index.js +0 -3
- package/dist/utils/parsing/jsonParser.d.ts +0 -80
- package/dist/utils/parsing/jsonParser.js +0 -133
- package/dist/utils/security/idGenerator.d.ts +0 -140
- package/dist/utils/security/idGenerator.js +0 -194
- package/dist/utils/security/index.d.ts +0 -3
- package/dist/utils/security/index.js +0 -3
- package/dist/utils/security/rateLimiter.d.ts +0 -156
- package/dist/utils/security/rateLimiter.js +0 -235
- package/dist/utils/security/sanitization.d.ts +0 -244
- package/dist/utils/security/sanitization.js +0 -599
|
@@ -1,583 +0,0 @@
|
|
|
1
|
-
import path from "node:path"; // For file path fallback logic using POSIX separators
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
|
|
4
|
-
import { createFormattedStatWithTokenCount, logger, retryWithDelay, } from "../../../utils/index.js";
|
|
5
|
-
// ====================================================================================
|
|
6
|
-
// Schema Definitions for Input Validation
|
|
7
|
-
// ====================================================================================
|
|
8
|
-
/** Defines the possible types of targets for the search/replace operation. */
|
|
9
|
-
const TargetTypeSchema = z
|
|
10
|
-
.enum(["filePath", "activeFile", "periodicNote"])
|
|
11
|
-
.describe("Specifies the target note: 'filePath', 'activeFile', or 'periodicNote'.");
|
|
12
|
-
/** Defines the valid periods for periodic notes. */
|
|
13
|
-
const PeriodicNotePeriodSchema = z
|
|
14
|
-
.enum(["daily", "weekly", "monthly", "quarterly", "yearly"])
|
|
15
|
-
.describe("Valid periods for 'periodicNote' target type.");
|
|
16
|
-
/**
|
|
17
|
-
* Defines the structure for a single search and replace operation block.
|
|
18
|
-
*/
|
|
19
|
-
const ReplacementBlockSchema = z.object({
|
|
20
|
-
/** The exact string or regex pattern to search for within the note content. Cannot be empty. */
|
|
21
|
-
search: z
|
|
22
|
-
.string()
|
|
23
|
-
.min(1, "Search pattern cannot be empty.")
|
|
24
|
-
.describe("The exact string or regex pattern to search for."),
|
|
25
|
-
/** The string to replace each match with. An empty string effectively deletes the matched text. */
|
|
26
|
-
replace: z.string().describe("The string to replace matches with."),
|
|
27
|
-
});
|
|
28
|
-
/**
|
|
29
|
-
* Base Zod schema object containing fields common to the search/replace tool input.
|
|
30
|
-
* This is used as the foundation for both the registration shape and the refined internal schema.
|
|
31
|
-
*/
|
|
32
|
-
const BaseObsidianSearchReplaceInputSchema = z.object({
|
|
33
|
-
/** Specifies the target note: 'filePath', 'activeFile', or 'periodicNote'. */
|
|
34
|
-
targetType: TargetTypeSchema,
|
|
35
|
-
/**
|
|
36
|
-
* Identifier for the target. Required and must be a vault-relative path if targetType is 'filePath'.
|
|
37
|
-
* Required and must be a valid period string (e.g., 'daily') if targetType is 'periodicNote'.
|
|
38
|
-
* Not used if targetType is 'activeFile'. The tool attempts a case-insensitive fallback if the exact filePath is not found.
|
|
39
|
-
*/
|
|
40
|
-
targetIdentifier: z
|
|
41
|
-
.string()
|
|
42
|
-
.optional()
|
|
43
|
-
.describe("Required if targetType is 'filePath' (vault-relative path) or 'periodicNote' (period string: 'daily', etc.). Tries case-insensitive fallback for filePath."),
|
|
44
|
-
/** An array of one or more search/replace operations to perform sequentially on the note content. */
|
|
45
|
-
replacements: z
|
|
46
|
-
.array(ReplacementBlockSchema)
|
|
47
|
-
.min(1, "Replacements array cannot be empty.")
|
|
48
|
-
.describe("An array of search/replace operations to perform sequentially."),
|
|
49
|
-
/** If true, treats the 'search' field in each replacement block as a JavaScript regular expression pattern. Defaults to false (exact string matching). */
|
|
50
|
-
useRegex: z
|
|
51
|
-
.boolean()
|
|
52
|
-
.optional()
|
|
53
|
-
.default(false)
|
|
54
|
-
.describe("If true, treat the 'search' field in replacements as JavaScript regex patterns. Defaults to false (exact string matching)."),
|
|
55
|
-
/** If true (default), replaces all occurrences matching each search pattern within the note. If false, replaces only the first occurrence of each pattern. */
|
|
56
|
-
replaceAll: z
|
|
57
|
-
.boolean()
|
|
58
|
-
.optional()
|
|
59
|
-
.default(true)
|
|
60
|
-
.describe("If true (default), replace all occurrences for each search pattern. If false, replace only the first occurrence."),
|
|
61
|
-
/** If true (default), the search operation is case-sensitive. If false, it's case-insensitive. Applies to both string and regex searches. */
|
|
62
|
-
caseSensitive: z
|
|
63
|
-
.boolean()
|
|
64
|
-
.optional()
|
|
65
|
-
.default(true)
|
|
66
|
-
.describe("If true (default), the search is case-sensitive. If false, it's case-insensitive. Applies to both string and regex search."),
|
|
67
|
-
/** If true (and useRegex is false), treats sequences of whitespace in the search string as matching one or more whitespace characters (\s+). Defaults to false. Cannot be true if useRegex is true. */
|
|
68
|
-
flexibleWhitespace: z
|
|
69
|
-
.boolean()
|
|
70
|
-
.optional()
|
|
71
|
-
.default(false)
|
|
72
|
-
.describe("If true (and useRegex=false), treats sequences of whitespace in the search string as matching one or more whitespace characters (\\s+). Defaults to false."),
|
|
73
|
-
/** If true, ensures the search term matches only whole words by implicitly adding word boundaries (\b) around the pattern (unless boundaries already exist in regex). Applies to both regex and non-regex modes. Defaults to false. */
|
|
74
|
-
wholeWord: z
|
|
75
|
-
.boolean()
|
|
76
|
-
.optional()
|
|
77
|
-
.default(false)
|
|
78
|
-
.describe("If true, ensures the search term matches only whole words using word boundaries (\\b). Applies to both regex and non-regex modes. Defaults to false."),
|
|
79
|
-
/** If true, includes the final content of the modified file in the response. Defaults to false. */
|
|
80
|
-
returnContent: z
|
|
81
|
-
.boolean()
|
|
82
|
-
.optional()
|
|
83
|
-
.default(false)
|
|
84
|
-
.describe("If true, returns the final content of the file in the response. Defaults to false."),
|
|
85
|
-
});
|
|
86
|
-
// ====================================================================================
|
|
87
|
-
// Refined Schema for Internal Logic and Strict Validation
|
|
88
|
-
// ====================================================================================
|
|
89
|
-
/**
|
|
90
|
-
* Refined Zod schema used internally within the tool's logic for strict validation.
|
|
91
|
-
* It builds upon `BaseObsidianSearchReplaceInputSchema` and adds cross-field validation rules:
|
|
92
|
-
* 1. Ensures `targetIdentifier` is provided and valid when required by `targetType`.
|
|
93
|
-
* 2. Ensures `flexibleWhitespace` is not used concurrently with `useRegex`.
|
|
94
|
-
*/
|
|
95
|
-
export const ObsidianSearchReplaceInputSchema = BaseObsidianSearchReplaceInputSchema.refine((data) => {
|
|
96
|
-
// Rule 1: Validate targetIdentifier based on targetType
|
|
97
|
-
if ((data.targetType === "filePath" ||
|
|
98
|
-
data.targetType === "periodicNote") &&
|
|
99
|
-
!data.targetIdentifier) {
|
|
100
|
-
return false; // Missing targetIdentifier
|
|
101
|
-
}
|
|
102
|
-
if (data.targetType === "periodicNote" &&
|
|
103
|
-
data.targetIdentifier &&
|
|
104
|
-
!PeriodicNotePeriodSchema.safeParse(data.targetIdentifier).success) {
|
|
105
|
-
return false; // Invalid period
|
|
106
|
-
}
|
|
107
|
-
// Rule 2: flexibleWhitespace cannot be true if useRegex is true
|
|
108
|
-
if (data.flexibleWhitespace && data.useRegex) {
|
|
109
|
-
return false; // Conflicting options
|
|
110
|
-
}
|
|
111
|
-
return true; // All checks passed
|
|
112
|
-
}, {
|
|
113
|
-
// Custom error message for refinement failures
|
|
114
|
-
message: "Validation failed: targetIdentifier is required and must be valid for 'filePath' or 'periodicNote'. Also, 'flexibleWhitespace' cannot be true if 'useRegex' is true.",
|
|
115
|
-
// Point error reporting to potentially problematic fields
|
|
116
|
-
path: ["targetIdentifier", "flexibleWhitespace", "useRegex"],
|
|
117
|
-
}).describe("Performs one or more search-and-replace operations within a target Obsidian note (file path, active, or periodic). Reads the file, applies replacements sequentially in memory, and writes the modified content back, overwriting the original. Supports string/regex search, case sensitivity toggle, replacing first/all occurrences, flexible whitespace matching (non-regex), and whole word matching.");
|
|
118
|
-
// ====================================================================================
|
|
119
|
-
// Schema Shape and Type Exports for Registration and Logic
|
|
120
|
-
// ====================================================================================
|
|
121
|
-
/**
|
|
122
|
-
* The shape of the base input schema, used by `server.tool` for registration and initial validation.
|
|
123
|
-
* @see BaseObsidianSearchReplaceInputSchema
|
|
124
|
-
*/
|
|
125
|
-
export const ObsidianSearchReplaceInputSchemaShape = BaseObsidianSearchReplaceInputSchema.shape;
|
|
126
|
-
// ====================================================================================
|
|
127
|
-
// Helper Functions
|
|
128
|
-
// ====================================================================================
|
|
129
|
-
/**
|
|
130
|
-
* Escapes characters that have special meaning in regular expressions.
|
|
131
|
-
* This allows treating a literal string as a pattern in a regex.
|
|
132
|
-
*
|
|
133
|
-
* @param {string} str - The input string to escape.
|
|
134
|
-
* @returns {string} The string with regex special characters escaped.
|
|
135
|
-
*/
|
|
136
|
-
function escapeRegex(str) {
|
|
137
|
-
// Escape characters: . * + ? ^ $ { } ( ) | [ ] \ -
|
|
138
|
-
return str.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&"); // $& inserts the matched character
|
|
139
|
-
}
|
|
140
|
-
/**
|
|
141
|
-
* Attempts to retrieve the final state (content and stats) of the target note after a search/replace operation.
|
|
142
|
-
* Uses the appropriate Obsidian API method based on the target type and the potentially corrected file path.
|
|
143
|
-
* Logs a warning and returns null if fetching the final state fails, preventing failure of the entire operation.
|
|
144
|
-
*
|
|
145
|
-
* @param {z.infer<typeof TargetTypeSchema>} targetType - The type of the target note.
|
|
146
|
-
* @param {string | undefined} effectiveFilePath - The vault-relative path (potentially corrected by case-insensitive fallback). Undefined for non-filePath targets.
|
|
147
|
-
* @param {z.infer<typeof PeriodicNotePeriodSchema> | undefined} period - The parsed period if targetType is 'periodicNote'.
|
|
148
|
-
* @param {ObsidianRestApiService} obsidianService - The Obsidian API service instance.
|
|
149
|
-
* @param {RequestContext} context - The request context for logging and correlation.
|
|
150
|
-
* @returns {Promise<NoteJson | null>} A promise resolving to the NoteJson object or null if retrieval fails.
|
|
151
|
-
*/
|
|
152
|
-
async function getFinalState(targetType, effectiveFilePath, period, obsidianService, context) {
|
|
153
|
-
const operation = "getFinalStateAfterSearchReplace";
|
|
154
|
-
const targetDesc = effectiveFilePath ?? (period ? `periodic ${period}` : "active file");
|
|
155
|
-
logger.debug(`Attempting to retrieve final state for target: ${targetDesc}`, {
|
|
156
|
-
...context,
|
|
157
|
-
operation,
|
|
158
|
-
});
|
|
159
|
-
try {
|
|
160
|
-
let noteJson = null;
|
|
161
|
-
// Call the appropriate API method
|
|
162
|
-
if (targetType === "filePath" && effectiveFilePath) {
|
|
163
|
-
noteJson = (await obsidianService.getFileContent(effectiveFilePath, "json", context));
|
|
164
|
-
}
|
|
165
|
-
else if (targetType === "activeFile") {
|
|
166
|
-
noteJson = (await obsidianService.getActiveFile("json", context));
|
|
167
|
-
}
|
|
168
|
-
else if (targetType === "periodicNote" && period) {
|
|
169
|
-
noteJson = (await obsidianService.getPeriodicNote(period, "json", context));
|
|
170
|
-
}
|
|
171
|
-
logger.debug(`Successfully retrieved final state for ${targetDesc}`, {
|
|
172
|
-
...context,
|
|
173
|
-
operation,
|
|
174
|
-
});
|
|
175
|
-
return noteJson;
|
|
176
|
-
}
|
|
177
|
-
catch (error) {
|
|
178
|
-
// Log the error but return null to avoid failing the main operation.
|
|
179
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
180
|
-
logger.warning(`Could not retrieve final state after search/replace for target: ${targetDesc}. Error: ${errorMsg}`, { ...context, operation, error: errorMsg });
|
|
181
|
-
return null;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
// ====================================================================================
|
|
185
|
-
// Core Logic Function
|
|
186
|
-
// ====================================================================================
|
|
187
|
-
/**
|
|
188
|
-
* Processes the core logic for the 'obsidian_search_replace' tool.
|
|
189
|
-
* Reads the target note content, performs a sequence of search and replace operations
|
|
190
|
-
* based on the provided parameters (handling regex, case sensitivity, etc.),
|
|
191
|
-
* writes the modified content back to Obsidian, retrieves the final state,
|
|
192
|
-
* and constructs the response object.
|
|
193
|
-
*
|
|
194
|
-
* @param {ObsidianSearchReplaceInput} params - The validated input parameters conforming to the refined schema.
|
|
195
|
-
* @param {RequestContext} context - The request context for logging and correlation.
|
|
196
|
-
* @param {ObsidianRestApiService} obsidianService - The instance of the Obsidian REST API service.
|
|
197
|
-
* @returns {Promise<ObsidianSearchReplaceResponse>} A promise resolving to the structured success response.
|
|
198
|
-
* @throws {McpError} Throws an McpError if validation fails, reading/writing fails, or an unexpected error occurs during processing.
|
|
199
|
-
*/
|
|
200
|
-
export const processObsidianSearchReplace = async (params, // Use the refined, validated type
|
|
201
|
-
context, obsidianService, vaultCacheService) => {
|
|
202
|
-
// Destructure validated parameters for easier access
|
|
203
|
-
const { targetType, targetIdentifier, replacements, useRegex: initialUseRegex, // Rename to avoid shadowing loop variable
|
|
204
|
-
replaceAll, caseSensitive, flexibleWhitespace, // Note: Cannot be true if initialUseRegex is true (enforced by schema)
|
|
205
|
-
wholeWord, returnContent, } = params;
|
|
206
|
-
let effectiveFilePath = targetIdentifier; // Store the path used (might be updated by fallback)
|
|
207
|
-
let targetDescription = targetIdentifier ?? "active file"; // For logging and error messages
|
|
208
|
-
let targetPeriod;
|
|
209
|
-
logger.debug(`Processing obsidian_search_replace request`, {
|
|
210
|
-
...context,
|
|
211
|
-
targetType,
|
|
212
|
-
targetIdentifier,
|
|
213
|
-
initialUseRegex,
|
|
214
|
-
flexibleWhitespace,
|
|
215
|
-
wholeWord,
|
|
216
|
-
returnContent,
|
|
217
|
-
});
|
|
218
|
-
// --- Step 1: Read Initial Content (with case-insensitive fallback for filePath) ---
|
|
219
|
-
let originalContent;
|
|
220
|
-
const readContext = { ...context, operation: "readFileContent" };
|
|
221
|
-
try {
|
|
222
|
-
if (targetType === "filePath") {
|
|
223
|
-
if (!targetIdentifier) {
|
|
224
|
-
// Should be caught by schema, but double-check
|
|
225
|
-
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "targetIdentifier is required for targetType 'filePath'.", readContext);
|
|
226
|
-
}
|
|
227
|
-
targetDescription = targetIdentifier; // Initial description
|
|
228
|
-
try {
|
|
229
|
-
// Attempt 1: Case-sensitive read
|
|
230
|
-
logger.debug(`Attempting to read file (case-sensitive): ${targetIdentifier}`, readContext);
|
|
231
|
-
originalContent = (await obsidianService.getFileContent(targetIdentifier, "markdown", readContext));
|
|
232
|
-
effectiveFilePath = targetIdentifier; // Confirm exact path worked
|
|
233
|
-
logger.debug(`Successfully read file using exact path: ${targetIdentifier}`, readContext);
|
|
234
|
-
}
|
|
235
|
-
catch (readError) {
|
|
236
|
-
// Attempt 2: Case-insensitive fallback if NOT_FOUND
|
|
237
|
-
if (readError instanceof McpError &&
|
|
238
|
-
readError.code === BaseErrorCode.NOT_FOUND) {
|
|
239
|
-
logger.info(`File not found with exact path: ${targetIdentifier}. Attempting case-insensitive fallback.`, readContext);
|
|
240
|
-
const dirname = path.posix.dirname(targetIdentifier);
|
|
241
|
-
const filenameLower = path.posix
|
|
242
|
-
.basename(targetIdentifier)
|
|
243
|
-
.toLowerCase();
|
|
244
|
-
// List directory contents (use root '/' if dirname is '.')
|
|
245
|
-
const dirToList = dirname === "." ? "/" : dirname;
|
|
246
|
-
const filesInDir = await obsidianService.listFiles(dirToList, readContext);
|
|
247
|
-
// Filter for files matching the lowercase basename
|
|
248
|
-
const matches = filesInDir.filter((f) => !f.endsWith("/") &&
|
|
249
|
-
path.posix.basename(f).toLowerCase() === filenameLower);
|
|
250
|
-
if (matches.length === 1) {
|
|
251
|
-
// Found exactly one match
|
|
252
|
-
const correctFilename = path.posix.basename(matches[0]);
|
|
253
|
-
effectiveFilePath = path.posix.join(dirname, correctFilename); // Construct the correct path
|
|
254
|
-
targetDescription = effectiveFilePath; // Update description for subsequent logs/errors
|
|
255
|
-
logger.info(`Found case-insensitive match: ${effectiveFilePath}. Reading content.`, readContext);
|
|
256
|
-
originalContent = (await obsidianService.getFileContent(effectiveFilePath, "markdown", readContext));
|
|
257
|
-
logger.debug(`Successfully read file using fallback path: ${effectiveFilePath}`, readContext);
|
|
258
|
-
}
|
|
259
|
-
else {
|
|
260
|
-
// Handle ambiguous (multiple matches) or no match found
|
|
261
|
-
const errorMsg = matches.length > 1
|
|
262
|
-
? `Read failed: Ambiguous case-insensitive matches found for '${targetIdentifier}' in directory '${dirToList}'. Matches: [${matches.join(", ")}]`
|
|
263
|
-
: `Read failed: File not found for '${targetIdentifier}' (case-insensitive fallback also failed in directory '${dirToList}').`;
|
|
264
|
-
logger.error(errorMsg, { ...readContext, matches });
|
|
265
|
-
// Use NOT_FOUND for no match, CONFLICT for ambiguity
|
|
266
|
-
throw new McpError(matches.length > 1
|
|
267
|
-
? BaseErrorCode.CONFLICT
|
|
268
|
-
: BaseErrorCode.NOT_FOUND, errorMsg, readContext);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
else {
|
|
272
|
-
// Re-throw errors other than NOT_FOUND during the initial read attempt
|
|
273
|
-
throw readError;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
else if (targetType === "activeFile") {
|
|
278
|
-
logger.debug(`Reading content from active file.`, readContext);
|
|
279
|
-
originalContent = (await obsidianService.getActiveFile("markdown", readContext));
|
|
280
|
-
targetDescription = "the active file";
|
|
281
|
-
effectiveFilePath = undefined; // Not applicable
|
|
282
|
-
logger.debug(`Successfully read active file content.`, readContext);
|
|
283
|
-
}
|
|
284
|
-
else {
|
|
285
|
-
// periodicNote
|
|
286
|
-
if (!targetIdentifier) {
|
|
287
|
-
// Should be caught by schema
|
|
288
|
-
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "targetIdentifier is required for targetType 'periodicNote'.", readContext);
|
|
289
|
-
}
|
|
290
|
-
// Parse period (already validated by refined schema)
|
|
291
|
-
targetPeriod = PeriodicNotePeriodSchema.parse(targetIdentifier);
|
|
292
|
-
targetDescription = `periodic note '${targetPeriod}'`;
|
|
293
|
-
effectiveFilePath = undefined; // Not applicable
|
|
294
|
-
logger.debug(`Reading content from ${targetDescription}.`, readContext);
|
|
295
|
-
originalContent = (await obsidianService.getPeriodicNote(targetPeriod, "markdown", readContext));
|
|
296
|
-
logger.debug(`Successfully read ${targetDescription} content.`, readContext);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
catch (error) {
|
|
300
|
-
// Catch and handle errors during the initial read phase
|
|
301
|
-
if (error instanceof McpError)
|
|
302
|
-
throw error; // Re-throw known McpErrors
|
|
303
|
-
const errorMessage = `Unexpected error reading target ${targetDescription} before search/replace.`;
|
|
304
|
-
logger.error(errorMessage, error instanceof Error ? error : undefined, readContext);
|
|
305
|
-
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`, readContext);
|
|
306
|
-
}
|
|
307
|
-
// --- Step 2: Perform Sequential Replacements ---
|
|
308
|
-
let modifiedContent = originalContent;
|
|
309
|
-
let totalReplacementsMade = 0;
|
|
310
|
-
const replaceContext = { ...context, operation: "performReplacements" };
|
|
311
|
-
logger.debug(`Starting ${replacements.length} replacement operations.`, replaceContext);
|
|
312
|
-
for (let i = 0; i < replacements.length; i++) {
|
|
313
|
-
const rep = replacements[i];
|
|
314
|
-
const repContext = {
|
|
315
|
-
...replaceContext,
|
|
316
|
-
replacementIndex: i,
|
|
317
|
-
searchPattern: rep.search,
|
|
318
|
-
};
|
|
319
|
-
let currentReplacementsInBlock = 0;
|
|
320
|
-
let finalSearchPattern = rep.search; // Start with the raw search string
|
|
321
|
-
let useRegexForThisRep = initialUseRegex; // Use the overall setting initially
|
|
322
|
-
try {
|
|
323
|
-
// --- 2a: Prepare the Search Pattern (Apply options) ---
|
|
324
|
-
const patternPrepContext = {
|
|
325
|
-
...repContext,
|
|
326
|
-
subOperation: "prepareSearchPattern",
|
|
327
|
-
};
|
|
328
|
-
if (!initialUseRegex) {
|
|
329
|
-
// Handle non-regex specific options: flexibleWhitespace and wholeWord
|
|
330
|
-
let searchStr = rep.search; // Work with a mutable string
|
|
331
|
-
if (flexibleWhitespace) {
|
|
332
|
-
// Convert to a regex string: escape special chars, then replace whitespace sequences with \s+
|
|
333
|
-
searchStr = escapeRegex(searchStr).replace(/\s+/g, "\\s+");
|
|
334
|
-
useRegexForThisRep = true; // Now treat it as a regex pattern string
|
|
335
|
-
logger.debug(`Applying flexibleWhitespace: "${rep.search}" -> /${searchStr}/`, patternPrepContext);
|
|
336
|
-
}
|
|
337
|
-
if (wholeWord) {
|
|
338
|
-
// Add word boundaries (\b) to the pattern string
|
|
339
|
-
// If flexibleWhitespace was applied, searchStr is already a regex string.
|
|
340
|
-
// Otherwise, escape the original search string first.
|
|
341
|
-
const basePattern = useRegexForThisRep
|
|
342
|
-
? searchStr
|
|
343
|
-
: escapeRegex(searchStr);
|
|
344
|
-
searchStr = `\\b${basePattern}\\b`;
|
|
345
|
-
useRegexForThisRep = true; // Definitely treat as a regex pattern string now
|
|
346
|
-
logger.debug(`Applying wholeWord: "${rep.search}" -> /${searchStr}/`, patternPrepContext);
|
|
347
|
-
}
|
|
348
|
-
finalSearchPattern = searchStr; // Update the pattern to use
|
|
349
|
-
}
|
|
350
|
-
else if (wholeWord) {
|
|
351
|
-
// Initial useRegex is true, but wholeWord is also requested.
|
|
352
|
-
// Apply wholeWord boundaries if the user's regex doesn't obviously have them.
|
|
353
|
-
let searchStr = rep.search;
|
|
354
|
-
// Heuristic check: Does the pattern likely already account for boundaries?
|
|
355
|
-
// Looks for ^, $, \b at start/end, or patterns matching full lines/non-whitespace sequences.
|
|
356
|
-
const hasBoundary = /(?:^|\\b)\S.*\S(?:$|\\b)|^\S$|^\S.*\S$|^$/.test(searchStr) ||
|
|
357
|
-
/^\^|\\b/.test(searchStr) ||
|
|
358
|
-
/\$|\\b$/.test(searchStr);
|
|
359
|
-
if (!hasBoundary) {
|
|
360
|
-
searchStr = `\\b${searchStr}\\b`;
|
|
361
|
-
// Log a warning as this might interfere with complex user regex.
|
|
362
|
-
logger.warning(`Applying wholeWord=true to user-provided regex. Original: /${rep.search}/, Modified: /${searchStr}/. This might affect complex regex behavior.`, patternPrepContext);
|
|
363
|
-
finalSearchPattern = searchStr; // Update the pattern string
|
|
364
|
-
}
|
|
365
|
-
else {
|
|
366
|
-
logger.debug(`wholeWord=true requested, but user regex /${searchStr}/ appears to already contain boundary anchors. Using original regex.`, patternPrepContext);
|
|
367
|
-
finalSearchPattern = rep.search; // Keep original regex string
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
// If it's still not treated as regex, finalSearchPattern remains the original rep.search string.
|
|
371
|
-
// --- 2b: Execute the Replacement ---
|
|
372
|
-
const execContext = {
|
|
373
|
-
...repContext,
|
|
374
|
-
subOperation: "executeReplacement",
|
|
375
|
-
isRegex: useRegexForThisRep,
|
|
376
|
-
};
|
|
377
|
-
if (useRegexForThisRep) {
|
|
378
|
-
// --- Regex Replacement ---
|
|
379
|
-
let flags = "";
|
|
380
|
-
if (replaceAll)
|
|
381
|
-
flags += "g"; // Global flag for all matches
|
|
382
|
-
if (!caseSensitive)
|
|
383
|
-
flags += "i"; // Ignore case flag
|
|
384
|
-
const regex = new RegExp(finalSearchPattern, flags); // Create RegExp object
|
|
385
|
-
logger.debug(`Executing regex replacement: /${finalSearchPattern}/${flags}`, execContext);
|
|
386
|
-
// Count matches *before* replacing to report accurately
|
|
387
|
-
const matches = modifiedContent.match(regex);
|
|
388
|
-
currentReplacementsInBlock = matches ? matches.length : 0;
|
|
389
|
-
// If replaceAll is false, we only perform/count one replacement, even if regex matches more
|
|
390
|
-
if (!replaceAll && currentReplacementsInBlock > 0) {
|
|
391
|
-
currentReplacementsInBlock = 1;
|
|
392
|
-
}
|
|
393
|
-
// Perform the replacement
|
|
394
|
-
if (currentReplacementsInBlock > 0) {
|
|
395
|
-
if (replaceAll) {
|
|
396
|
-
modifiedContent = modifiedContent.replace(regex, rep.replace);
|
|
397
|
-
}
|
|
398
|
-
else {
|
|
399
|
-
// Replace only the first occurrence found by the regex
|
|
400
|
-
modifiedContent = modifiedContent.replace(regex, rep.replace);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
else {
|
|
405
|
-
// --- Simple String Replacement ---
|
|
406
|
-
// Note: wholeWord and flexibleWhitespace would have set useRegexForThisRep = true
|
|
407
|
-
const searchString = finalSearchPattern; // It's just a string here
|
|
408
|
-
const comparisonString = caseSensitive
|
|
409
|
-
? searchString
|
|
410
|
-
: searchString.toLowerCase();
|
|
411
|
-
let startIndex = 0;
|
|
412
|
-
logger.debug(`Executing string replacement: "${searchString}" (caseSensitive: ${caseSensitive})`, execContext);
|
|
413
|
-
while (true) {
|
|
414
|
-
const contentToSearch = caseSensitive
|
|
415
|
-
? modifiedContent
|
|
416
|
-
: modifiedContent.toLowerCase();
|
|
417
|
-
const index = contentToSearch.indexOf(comparisonString, startIndex);
|
|
418
|
-
if (index === -1) {
|
|
419
|
-
break; // No more occurrences found
|
|
420
|
-
}
|
|
421
|
-
currentReplacementsInBlock++;
|
|
422
|
-
// Perform replacement using original indices and search string length
|
|
423
|
-
modifiedContent =
|
|
424
|
-
modifiedContent.substring(0, index) +
|
|
425
|
-
rep.replace +
|
|
426
|
-
modifiedContent.substring(index + searchString.length);
|
|
427
|
-
if (!replaceAll) {
|
|
428
|
-
break; // Stop after the first replacement
|
|
429
|
-
}
|
|
430
|
-
// Move start index past the inserted replacement to find the next match
|
|
431
|
-
startIndex = index + rep.replace.length;
|
|
432
|
-
// Safety break for empty search string or potential infinite loops
|
|
433
|
-
if (searchString.length === 0) {
|
|
434
|
-
logger.warning(`Search string is empty. Breaking replacement loop to prevent infinite execution.`, execContext);
|
|
435
|
-
break;
|
|
436
|
-
}
|
|
437
|
-
// Basic check if replacement could cause infinite loop (e.g., replacing 'a' with 'ba')
|
|
438
|
-
if (rep.replace.includes(searchString) &&
|
|
439
|
-
rep.replace.length >= searchString.length) {
|
|
440
|
-
// This is a heuristic, might not catch all cases but prevents common ones.
|
|
441
|
-
logger.warning(`Replacement string "${rep.replace}" contains search string "${searchString}". Potential infinite loop detected. Breaking loop for this block.`, execContext);
|
|
442
|
-
break;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
totalReplacementsMade += currentReplacementsInBlock;
|
|
447
|
-
logger.debug(`Block ${i}: Performed ${currentReplacementsInBlock} replacements for search: "${rep.search}"`, repContext);
|
|
448
|
-
}
|
|
449
|
-
catch (error) {
|
|
450
|
-
// Catch errors during a specific replacement block
|
|
451
|
-
const errorMessage = `Error during replacement block ${i} (search: "${rep.search}")`;
|
|
452
|
-
logger.error(errorMessage, error instanceof Error ? error : undefined, repContext);
|
|
453
|
-
// Fail fast: Stop processing further replacements if one block fails.
|
|
454
|
-
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `${errorMessage}: ${error instanceof Error ? error.message : "Unknown error"}`, repContext);
|
|
455
|
-
}
|
|
456
|
-
} // End of replacements loop
|
|
457
|
-
logger.debug(`Finished all replacement operations. Total replacements made: ${totalReplacementsMade}`, replaceContext);
|
|
458
|
-
// --- Step 3: Write Modified Content Back to Obsidian ---
|
|
459
|
-
let finalState = null;
|
|
460
|
-
const POST_UPDATE_DELAY_MS = 500; // Delay before trying to read the file back
|
|
461
|
-
// Only write back if the content actually changed to avoid unnecessary file operations.
|
|
462
|
-
if (modifiedContent !== originalContent) {
|
|
463
|
-
const writeContext = { ...context, operation: "writeFileContent" };
|
|
464
|
-
try {
|
|
465
|
-
logger.debug(`Content changed. Writing modified content back to ${targetDescription}`, writeContext);
|
|
466
|
-
// Use the effectiveFilePath determined during the read phase for filePath targets
|
|
467
|
-
if (targetType === "filePath") {
|
|
468
|
-
await obsidianService.updateFileContent(effectiveFilePath, modifiedContent, writeContext);
|
|
469
|
-
if (vaultCacheService) {
|
|
470
|
-
await vaultCacheService.updateCacheForFile(effectiveFilePath, writeContext);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
else if (targetType === "activeFile") {
|
|
474
|
-
await obsidianService.updateActiveFile(modifiedContent, writeContext);
|
|
475
|
-
}
|
|
476
|
-
else {
|
|
477
|
-
// periodicNote
|
|
478
|
-
await obsidianService.updatePeriodicNote(targetPeriod, modifiedContent, writeContext);
|
|
479
|
-
}
|
|
480
|
-
logger.info(`Successfully updated ${targetDescription} with ${totalReplacementsMade} replacement(s).`, writeContext);
|
|
481
|
-
// Attempt to get the final state *after* successfully writing.
|
|
482
|
-
logger.debug(`Waiting ${POST_UPDATE_DELAY_MS}ms before retrieving final state after write...`, { ...writeContext, subOperation: "postWriteDelay" });
|
|
483
|
-
await new Promise((resolve) => setTimeout(resolve, POST_UPDATE_DELAY_MS));
|
|
484
|
-
try {
|
|
485
|
-
finalState = await retryWithDelay(async () => getFinalState(targetType, effectiveFilePath, targetPeriod, obsidianService, context), {
|
|
486
|
-
operationName: "getFinalStateAfterSearchReplaceWrite",
|
|
487
|
-
context: {
|
|
488
|
-
...context,
|
|
489
|
-
operation: "getFinalStateAfterSearchReplaceWriteAttempt",
|
|
490
|
-
},
|
|
491
|
-
maxRetries: 3,
|
|
492
|
-
delayMs: 300,
|
|
493
|
-
shouldRetry: (err) => err instanceof McpError &&
|
|
494
|
-
(err.code === BaseErrorCode.NOT_FOUND ||
|
|
495
|
-
err.code === BaseErrorCode.SERVICE_UNAVAILABLE ||
|
|
496
|
-
err.code === BaseErrorCode.TIMEOUT),
|
|
497
|
-
onRetry: (attempt, err) => logger.warning(`getFinalStateAfterSearchReplaceWrite (attempt ${attempt}) failed. Error: ${err.message}. Retrying...`, writeContext),
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
|
-
catch (retryError) {
|
|
501
|
-
finalState = null;
|
|
502
|
-
logger.error(`Failed to retrieve final state for ${targetDescription} after write, even after retries. Error: ${retryError.message}`, retryError instanceof Error ? retryError : undefined, writeContext);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
catch (error) {
|
|
506
|
-
// Handle errors during the write phase
|
|
507
|
-
if (error instanceof McpError)
|
|
508
|
-
throw error; // Re-throw known McpErrors
|
|
509
|
-
const errorMessage = `Unexpected error writing modified content to ${targetDescription}.`;
|
|
510
|
-
logger.error(errorMessage, error instanceof Error ? error : undefined, writeContext);
|
|
511
|
-
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `${errorMessage}: ${error instanceof Error ? error.message : String(error)}`, writeContext);
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
else {
|
|
515
|
-
// Content did not change, no need to write.
|
|
516
|
-
logger.info(`No changes detected in ${targetDescription} after search/replace operations. Skipping write.`, context);
|
|
517
|
-
// Still attempt to get the state, as the user might want stats even if content is unchanged.
|
|
518
|
-
logger.debug(`Waiting ${POST_UPDATE_DELAY_MS}ms before retrieving final state (no change)...`, { ...context, subOperation: "postNoChangeDelay" });
|
|
519
|
-
await new Promise((resolve) => setTimeout(resolve, POST_UPDATE_DELAY_MS));
|
|
520
|
-
try {
|
|
521
|
-
finalState = await retryWithDelay(async () => getFinalState(targetType, effectiveFilePath, targetPeriod, obsidianService, context), {
|
|
522
|
-
operationName: "getFinalStateAfterSearchReplaceNoChange",
|
|
523
|
-
context: {
|
|
524
|
-
...context,
|
|
525
|
-
operation: "getFinalStateAfterSearchReplaceNoChangeAttempt",
|
|
526
|
-
},
|
|
527
|
-
maxRetries: 3,
|
|
528
|
-
delayMs: 300,
|
|
529
|
-
shouldRetry: (err) => err instanceof McpError &&
|
|
530
|
-
(err.code === BaseErrorCode.NOT_FOUND ||
|
|
531
|
-
err.code === BaseErrorCode.SERVICE_UNAVAILABLE ||
|
|
532
|
-
err.code === BaseErrorCode.TIMEOUT),
|
|
533
|
-
onRetry: (attempt, err) => logger.warning(`getFinalStateAfterSearchReplaceNoChange (attempt ${attempt}) failed. Error: ${err.message}. Retrying...`, context),
|
|
534
|
-
});
|
|
535
|
-
}
|
|
536
|
-
catch (retryError) {
|
|
537
|
-
finalState = null;
|
|
538
|
-
logger.error(`Failed to retrieve final state for ${targetDescription} (no change), even after retries. Error: ${retryError.message}`, retryError instanceof Error ? retryError : undefined, context);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
// --- Step 4: Construct and Return the Response ---
|
|
542
|
-
const responseContext = { ...context, operation: "buildResponse" };
|
|
543
|
-
let message;
|
|
544
|
-
if (totalReplacementsMade > 0) {
|
|
545
|
-
message = `Search/replace completed on ${targetDescription}. Successfully made ${totalReplacementsMade} replacement(s).`;
|
|
546
|
-
}
|
|
547
|
-
else if (modifiedContent !== originalContent) {
|
|
548
|
-
// This case should ideally not happen if totalReplacementsMade is 0, but as a safeguard:
|
|
549
|
-
message = `Search/replace completed on ${targetDescription}. Content was modified, but replacement count is zero. Please review.`;
|
|
550
|
-
}
|
|
551
|
-
else {
|
|
552
|
-
message = `Search/replace completed on ${targetDescription}. No matching text was found, so no replacements were made.`;
|
|
553
|
-
}
|
|
554
|
-
// Append a warning if the final state couldn't be retrieved
|
|
555
|
-
if (finalState === null) {
|
|
556
|
-
const warningMsg = " (Warning: Could not retrieve final file stats/content after update.)";
|
|
557
|
-
message += warningMsg;
|
|
558
|
-
logger.warning(`Appending warning to response message: ${warningMsg}`, responseContext);
|
|
559
|
-
}
|
|
560
|
-
// Format the file statistics using the shared utility.
|
|
561
|
-
// Use final state content if available, otherwise use the (potentially modified) content in memory for token count.
|
|
562
|
-
const finalContentForStat = finalState?.content ?? modifiedContent;
|
|
563
|
-
const formattedStatResult = finalState?.stat
|
|
564
|
-
? await createFormattedStatWithTokenCount(finalState.stat, finalContentForStat, responseContext) // Await the async utility
|
|
565
|
-
: undefined;
|
|
566
|
-
// Ensure stat is undefined if the utility returned null (e.g., token counting failed)
|
|
567
|
-
const formattedStat = formattedStatResult === null ? undefined : formattedStatResult;
|
|
568
|
-
// Build the final response object
|
|
569
|
-
const response = {
|
|
570
|
-
success: true,
|
|
571
|
-
message: message,
|
|
572
|
-
totalReplacementsMade,
|
|
573
|
-
stats: formattedStat,
|
|
574
|
-
};
|
|
575
|
-
// Include final content if requested and available.
|
|
576
|
-
if (returnContent) {
|
|
577
|
-
// Prefer content from final state read, fallback to in-memory modified content.
|
|
578
|
-
response.finalContent = finalState?.content ?? modifiedContent;
|
|
579
|
-
logger.debug(`Including final content in response as requested.`, responseContext);
|
|
580
|
-
}
|
|
581
|
-
logger.debug(`Search/replace process completed successfully.`, responseContext);
|
|
582
|
-
return response;
|
|
583
|
-
};
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import { ObsidianRestApiService, VaultCacheService } from "../../../services/obsidianRestAPI/index.js";
|
|
3
|
-
/**
|
|
4
|
-
* Registers the 'obsidian_search_replace' tool with the MCP server.
|
|
5
|
-
*
|
|
6
|
-
* This tool performs one or more search-and-replace operations within a specified
|
|
7
|
-
* Obsidian note (identified by file path, the active file, or a periodic note).
|
|
8
|
-
* It reads the note content, applies the replacements sequentially based on the
|
|
9
|
-
* provided options (regex, case sensitivity, etc.), writes the modified content
|
|
10
|
-
* back to the vault, and returns the operation results.
|
|
11
|
-
*
|
|
12
|
-
* The response includes success status, a summary message, the total number of
|
|
13
|
-
* replacements made, formatted file statistics (timestamp, token count), and
|
|
14
|
-
* optionally the final content of the note.
|
|
15
|
-
*
|
|
16
|
-
* @param {McpServer} server - The MCP server instance to register the tool with.
|
|
17
|
-
* @param {ObsidianRestApiService} obsidianService - An instance of the Obsidian REST API service
|
|
18
|
-
* used to interact with the user's Obsidian vault.
|
|
19
|
-
* @returns {Promise<void>} A promise that resolves when the tool registration is complete or rejects on error.
|
|
20
|
-
* @throws {McpError} Throws an McpError if registration fails critically.
|
|
21
|
-
*/
|
|
22
|
-
export declare const registerObsidianSearchReplaceTool: (server: McpServer, obsidianService: ObsidianRestApiService, vaultCacheService: VaultCacheService | undefined) => Promise<void>;
|