This is page 2 of 2. Use http://codebase.md/stevenstavrakis/obsidian-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .github │ └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── bun.lockb ├── docs │ ├── creating-tools.md │ └── tool-examples.md ├── example.ts ├── LICENSE ├── package.json ├── README.md ├── src │ ├── main.ts │ ├── prompts │ │ └── list-vaults │ │ └── index.ts │ ├── resources │ │ ├── index.ts │ │ ├── resources.ts │ │ └── vault │ │ └── index.ts │ ├── server.ts │ ├── tools │ │ ├── add-tags │ │ │ └── index.ts │ │ ├── create-directory │ │ │ └── index.ts │ │ ├── create-note │ │ │ └── index.ts │ │ ├── delete-note │ │ │ └── index.ts │ │ ├── edit-note │ │ │ └── index.ts │ │ ├── list-available-vaults │ │ │ └── index.ts │ │ ├── manage-tags │ │ │ └── index.ts │ │ ├── move-note │ │ │ └── index.ts │ │ ├── read-note │ │ │ └── index.ts │ │ ├── remove-tags │ │ │ └── index.ts │ │ ├── rename-tag │ │ │ └── index.ts │ │ └── search-vault │ │ └── index.ts │ ├── types.ts │ └── utils │ ├── errors.ts │ ├── files.ts │ ├── links.ts │ ├── path.test.ts │ ├── path.ts │ ├── prompt-factory.ts │ ├── responses.ts │ ├── schema.ts │ ├── security.ts │ ├── tags.ts │ ├── tool-factory.ts │ └── vault-resolver.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /src/utils/tags.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | 4 | interface ParsedNote { 5 | frontmatter: Record<string, any>; 6 | content: string; 7 | hasFrontmatter: boolean; 8 | } 9 | 10 | interface TagChange { 11 | tag: string; 12 | location: 'frontmatter' | 'content'; 13 | line?: number; 14 | context?: string; 15 | } 16 | 17 | interface TagRemovalReport { 18 | removedTags: TagChange[]; 19 | preservedTags: TagChange[]; 20 | errors: string[]; 21 | } 22 | 23 | /** 24 | * Checks if tagA is a parent of tagB in a hierarchical structure 25 | */ 26 | export function isParentTag(parentTag: string, childTag: string): boolean { 27 | return childTag.startsWith(parentTag + '/'); 28 | } 29 | 30 | /** 31 | * Matches a tag against a pattern 32 | * Supports * wildcard and hierarchical matching 33 | */ 34 | export function matchesTagPattern(pattern: string, tag: string): boolean { 35 | // Convert glob pattern to regex 36 | const regexPattern = pattern 37 | .replace(/\*/g, '.*') 38 | .replace(/\//g, '\\/'); 39 | return new RegExp(`^${regexPattern}$`).test(tag); 40 | } 41 | 42 | /** 43 | * Gets all related tags (parent/child) for a given tag 44 | */ 45 | export function getRelatedTags(tag: string, allTags: string[]): { 46 | parents: string[]; 47 | children: string[]; 48 | } { 49 | const parents: string[] = []; 50 | const children: string[] = []; 51 | 52 | const parts = tag.split('/'); 53 | let current = ''; 54 | 55 | // Find parents 56 | for (let i = 0; i < parts.length - 1; i++) { 57 | current = current ? `${current}/${parts[i]}` : parts[i]; 58 | parents.push(current); 59 | } 60 | 61 | // Find children 62 | allTags.forEach(otherTag => { 63 | if (isParentTag(tag, otherTag)) { 64 | children.push(otherTag); 65 | } 66 | }); 67 | 68 | return { parents, children }; 69 | } 70 | 71 | /** 72 | * Validates a tag format 73 | * Allows: #tag, tag, tag/subtag, project/active 74 | * Disallows: empty strings, spaces, special characters except '/' 75 | */ 76 | export function validateTag(tag: string): boolean { 77 | // Remove leading # if present 78 | tag = tag.replace(/^#/, ''); 79 | 80 | // Check if tag is empty 81 | if (!tag) return false; 82 | 83 | // Basic tag format validation 84 | const TAG_REGEX = /^[a-zA-Z0-9]+(\/[a-zA-Z0-9]+)*$/; 85 | return TAG_REGEX.test(tag); 86 | } 87 | 88 | /** 89 | * Normalizes a tag to a consistent format 90 | * Example: ProjectActive -> project-active 91 | */ 92 | export function normalizeTag(tag: string, normalize = true): string { 93 | // Remove leading # if present 94 | tag = tag.replace(/^#/, ''); 95 | 96 | if (!normalize) return tag; 97 | 98 | // Convert camelCase/PascalCase to kebab-case 99 | return tag 100 | .split('/') 101 | .map(part => 102 | part 103 | .replace(/([a-z0-9])([A-Z])/g, '$1-$2') 104 | .toLowerCase() 105 | ) 106 | .join('/'); 107 | } 108 | 109 | /** 110 | * Parses a note's content into frontmatter and body 111 | */ 112 | export function parseNote(content: string): ParsedNote { 113 | const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; 114 | const match = content.match(frontmatterRegex); 115 | 116 | if (!match) { 117 | return { 118 | frontmatter: {}, 119 | content: content, 120 | hasFrontmatter: false 121 | }; 122 | } 123 | 124 | try { 125 | const frontmatter = parseYaml(match[1]); 126 | return { 127 | frontmatter: frontmatter || {}, 128 | content: match[2], 129 | hasFrontmatter: true 130 | }; 131 | } catch (error) { 132 | throw new McpError( 133 | ErrorCode.InvalidParams, 134 | 'Invalid frontmatter YAML format' 135 | ); 136 | } 137 | } 138 | 139 | /** 140 | * Combines frontmatter and content back into a note 141 | */ 142 | export function stringifyNote(parsed: ParsedNote): string { 143 | if (!parsed.hasFrontmatter || Object.keys(parsed.frontmatter).length === 0) { 144 | return parsed.content; 145 | } 146 | 147 | const frontmatterStr = stringifyYaml(parsed.frontmatter).trim(); 148 | return `---\n${frontmatterStr}\n---\n\n${parsed.content.trim()}`; 149 | } 150 | 151 | /** 152 | * Extracts all tags from a note's content 153 | */ 154 | export function extractTags(content: string): string[] { 155 | const tags = new Set<string>(); 156 | 157 | // Match hashtags that aren't inside code blocks or HTML comments 158 | const TAG_PATTERN = /(?<!`)#[a-zA-Z0-9][a-zA-Z0-9/]*(?!`)/g; 159 | 160 | // Split content into lines 161 | const lines = content.split('\n'); 162 | let inCodeBlock = false; 163 | let inHtmlComment = false; 164 | 165 | for (const line of lines) { 166 | // Check for code block boundaries 167 | if (line.trim().startsWith('```')) { 168 | inCodeBlock = !inCodeBlock; 169 | continue; 170 | } 171 | 172 | // Check for HTML comment boundaries 173 | if (line.includes('<!--')) inHtmlComment = true; 174 | if (line.includes('-->')) inHtmlComment = false; 175 | 176 | // Skip if we're in a code block or HTML comment 177 | if (inCodeBlock || inHtmlComment) continue; 178 | 179 | // Extract tags from the line 180 | const matches = line.match(TAG_PATTERN); 181 | if (matches) { 182 | matches.forEach(tag => tags.add(tag.slice(1))); // Remove # prefix 183 | } 184 | } 185 | 186 | return Array.from(tags); 187 | } 188 | 189 | /** 190 | * Safely adds tags to frontmatter 191 | */ 192 | export function addTagsToFrontmatter( 193 | frontmatter: Record<string, any>, 194 | newTags: string[], 195 | normalize = true 196 | ): Record<string, any> { 197 | const updatedFrontmatter = { ...frontmatter }; 198 | const existingTags = new Set( 199 | Array.isArray(frontmatter.tags) ? frontmatter.tags : [] 200 | ); 201 | 202 | for (const tag of newTags) { 203 | if (!validateTag(tag)) { 204 | throw new McpError( 205 | ErrorCode.InvalidParams, 206 | `Invalid tag format: ${tag}` 207 | ); 208 | } 209 | existingTags.add(normalizeTag(tag, normalize)); 210 | } 211 | 212 | updatedFrontmatter.tags = Array.from(existingTags).sort(); 213 | return updatedFrontmatter; 214 | } 215 | 216 | /** 217 | * Safely removes tags from frontmatter with detailed reporting 218 | */ 219 | export function removeTagsFromFrontmatter( 220 | frontmatter: Record<string, any>, 221 | tagsToRemove: string[], 222 | options: { 223 | normalize?: boolean; 224 | preserveChildren?: boolean; 225 | patterns?: string[]; 226 | } = {} 227 | ): { 228 | frontmatter: Record<string, any>; 229 | report: { 230 | removed: TagChange[]; 231 | preserved: TagChange[]; 232 | }; 233 | } { 234 | const { 235 | normalize = true, 236 | preserveChildren = false, 237 | patterns = [] 238 | } = options; 239 | 240 | const updatedFrontmatter = { ...frontmatter }; 241 | const existingTags = Array.isArray(frontmatter.tags) ? frontmatter.tags : []; 242 | const removed: TagChange[] = []; 243 | const preserved: TagChange[] = []; 244 | 245 | // Get all related tags if preserving children 246 | const relatedTagsMap = new Map( 247 | tagsToRemove.map(tag => [ 248 | tag, 249 | preserveChildren ? getRelatedTags(tag, existingTags) : null 250 | ]) 251 | ); 252 | 253 | const newTags = existingTags.filter(tag => { 254 | const normalizedTag = normalizeTag(tag, normalize); 255 | 256 | // Check if tag should be removed 257 | const shouldRemove = tagsToRemove.some(removeTag => { 258 | // Direct match 259 | if (normalizeTag(removeTag, normalize) === normalizedTag) return true; 260 | 261 | // Pattern match 262 | if (patterns.some(pattern => matchesTagPattern(pattern, normalizedTag))) { 263 | return true; 264 | } 265 | 266 | // Hierarchical match (if not preserving children) 267 | if (!preserveChildren) { 268 | const related = relatedTagsMap.get(removeTag); 269 | if (related?.parents.includes(normalizedTag)) return true; 270 | } 271 | 272 | return false; 273 | }); 274 | 275 | if (shouldRemove) { 276 | removed.push({ 277 | tag: normalizedTag, 278 | location: 'frontmatter' 279 | }); 280 | return false; 281 | } else { 282 | preserved.push({ 283 | tag: normalizedTag, 284 | location: 'frontmatter' 285 | }); 286 | return true; 287 | } 288 | }); 289 | 290 | updatedFrontmatter.tags = newTags.sort(); 291 | return { 292 | frontmatter: updatedFrontmatter, 293 | report: { removed, preserved } 294 | }; 295 | } 296 | 297 | /** 298 | * Removes inline tags from content with detailed reporting 299 | */ 300 | export function removeInlineTags( 301 | content: string, 302 | tagsToRemove: string[], 303 | options: { 304 | normalize?: boolean; 305 | preserveChildren?: boolean; 306 | patterns?: string[]; 307 | } = {} 308 | ): { 309 | content: string; 310 | report: { 311 | removed: TagChange[]; 312 | preserved: TagChange[]; 313 | }; 314 | } { 315 | const { 316 | normalize = true, 317 | preserveChildren = false, 318 | patterns = [] 319 | } = options; 320 | 321 | const removed: TagChange[] = []; 322 | const preserved: TagChange[] = []; 323 | 324 | // Process content line by line to track context 325 | const lines = content.split('\n'); 326 | let inCodeBlock = false; 327 | let inHtmlComment = false; 328 | let modifiedLines = lines.map((line, lineNum) => { 329 | // Track code blocks and comments 330 | if (line.trim().startsWith('```')) { 331 | inCodeBlock = !inCodeBlock; 332 | return line; 333 | } 334 | if (line.includes('<!--')) inHtmlComment = true; 335 | if (line.includes('-->')) inHtmlComment = false; 336 | if (inCodeBlock || inHtmlComment) { 337 | // Preserve tags in code blocks and comments 338 | const tags = line.match(/(?<!`)#[a-zA-Z0-9][a-zA-Z0-9/]*(?!`)/g) || []; 339 | tags.forEach(tag => { 340 | preserved.push({ 341 | tag: tag.slice(1), 342 | location: 'content', 343 | line: lineNum + 1, 344 | context: line.trim() 345 | }); 346 | }); 347 | return line; 348 | } 349 | 350 | // Process tags in regular content 351 | return line.replace( 352 | /(?<!`)#[a-zA-Z0-9][a-zA-Z0-9/]*(?!`)/g, 353 | (match) => { 354 | const tag = match.slice(1); // Remove # prefix 355 | const normalizedTag = normalizeTag(tag, normalize); 356 | 357 | const shouldRemove = tagsToRemove.some(removeTag => { 358 | // Direct match 359 | if (normalizeTag(removeTag, normalize) === normalizedTag) return true; 360 | 361 | // Pattern match 362 | if (patterns.some(pattern => matchesTagPattern(pattern, normalizedTag))) { 363 | return true; 364 | } 365 | 366 | // Hierarchical match (if not preserving children) 367 | if (!preserveChildren && isParentTag(removeTag, normalizedTag)) { 368 | return true; 369 | } 370 | 371 | return false; 372 | }); 373 | 374 | if (shouldRemove) { 375 | removed.push({ 376 | tag: normalizedTag, 377 | location: 'content', 378 | line: lineNum + 1, 379 | context: line.trim() 380 | }); 381 | return ''; 382 | } else { 383 | preserved.push({ 384 | tag: normalizedTag, 385 | location: 'content', 386 | line: lineNum + 1, 387 | context: line.trim() 388 | }); 389 | return match; 390 | } 391 | } 392 | ); 393 | }); 394 | 395 | // Clean up empty lines created by tag removal 396 | modifiedLines = modifiedLines.reduce((acc: string[], line: string) => { 397 | if (line.trim() === '') { 398 | if (acc[acc.length - 1]?.trim() === '') { 399 | return acc; 400 | } 401 | } 402 | acc.push(line); 403 | return acc; 404 | }, []); 405 | 406 | return { 407 | content: modifiedLines.join('\n'), 408 | report: { removed, preserved } 409 | }; 410 | } 411 | ``` -------------------------------------------------------------------------------- /src/tools/rename-tag/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 3 | import { promises as fs } from "fs"; 4 | import path from "path"; 5 | import { 6 | validateTag, 7 | normalizeTag, 8 | parseNote, 9 | stringifyNote 10 | } from "../../utils/tags.js"; 11 | import { 12 | getAllMarkdownFiles, 13 | safeReadFile, 14 | fileExists 15 | } from "../../utils/files.js"; 16 | import { createTool } from "../../utils/tool-factory.js"; 17 | 18 | // Input validation schema with descriptions 19 | const schema = z.object({ 20 | vault: z.string() 21 | .min(1, "Vault name cannot be empty") 22 | .describe("Name of the vault containing the tags"), 23 | oldTag: z.string() 24 | .min(1, "Old tag must not be empty") 25 | .refine( 26 | tag => /^[a-zA-Z0-9\/]+$/.test(tag), 27 | "Tags must contain only letters, numbers, and forward slashes. Do not include the # symbol. Examples: 'project', 'work/active', 'tasks/2024/q1'" 28 | ) 29 | .describe("The tag to rename (without #). Example: 'project' or 'work/active'"), 30 | newTag: z.string() 31 | .min(1, "New tag must not be empty") 32 | .refine( 33 | tag => /^[a-zA-Z0-9\/]+$/.test(tag), 34 | "Tags must contain only letters, numbers, and forward slashes. Do not include the # symbol. Examples: 'project', 'work/active', 'tasks/2024/q1'" 35 | ) 36 | .describe("The new tag name (without #). Example: 'projects' or 'work/current'"), 37 | createBackup: z.boolean() 38 | .default(true) 39 | .describe("Whether to create a backup before making changes (default: true)"), 40 | normalize: z.boolean() 41 | .default(true) 42 | .describe("Whether to normalize tag names (e.g., ProjectActive -> project-active) (default: true)"), 43 | batchSize: z.number() 44 | .min(1) 45 | .max(100) 46 | .default(50) 47 | .describe("Number of files to process in each batch (1-100) (default: 50)") 48 | }).strict(); 49 | 50 | // Types 51 | type RenameTagInput = z.infer<typeof schema>; 52 | 53 | interface TagReplacement { 54 | oldTag: string; 55 | newTag: string; 56 | } 57 | 58 | interface TagChangeReport { 59 | filePath: string; 60 | oldTags: string[]; 61 | newTags: string[]; 62 | location: 'frontmatter' | 'content'; 63 | line?: number; 64 | } 65 | 66 | interface RenameTagReport { 67 | successful: TagChangeReport[]; 68 | failed: { 69 | filePath: string; 70 | error: string; 71 | }[]; 72 | timestamp: string; 73 | backupCreated?: string; 74 | } 75 | 76 | /** 77 | * Creates a backup of the vault 78 | */ 79 | async function createVaultBackup(vaultPath: string): Promise<string> { 80 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); 81 | const backupDir = path.join(vaultPath, '.backup'); 82 | const backupPath = path.join(backupDir, `vault-backup-${timestamp}`); 83 | 84 | await fs.mkdir(backupDir, { recursive: true }); 85 | 86 | // Copy all markdown files to backup 87 | const files = await getAllMarkdownFiles(vaultPath); 88 | for (const file of files) { 89 | const relativePath = path.relative(vaultPath, file); 90 | const backupFile = path.join(backupPath, relativePath); 91 | await fs.mkdir(path.dirname(backupFile), { recursive: true }); 92 | await fs.copyFile(file, backupFile); 93 | } 94 | 95 | return backupPath; 96 | } 97 | 98 | /** 99 | * Updates tags in frontmatter 100 | */ 101 | function updateFrontmatterTags( 102 | frontmatter: Record<string, any>, 103 | replacements: TagReplacement[], 104 | normalize: boolean 105 | ): { 106 | frontmatter: Record<string, any>; 107 | changes: { oldTag: string; newTag: string }[]; 108 | } { 109 | const changes: { oldTag: string; newTag: string }[] = []; 110 | const updatedFrontmatter = { ...frontmatter }; 111 | 112 | if (!Array.isArray(frontmatter.tags)) { 113 | return { frontmatter: updatedFrontmatter, changes }; 114 | } 115 | 116 | const updatedTags = frontmatter.tags.map(tag => { 117 | const normalizedTag = normalizeTag(tag, normalize); 118 | 119 | for (const { oldTag, newTag } of replacements) { 120 | const normalizedOldTag = normalizeTag(oldTag, normalize); 121 | 122 | if (normalizedTag === normalizedOldTag || 123 | normalizedTag.startsWith(normalizedOldTag + '/')) { 124 | const updatedTag = normalizedTag.replace( 125 | new RegExp(`^${normalizedOldTag}`), 126 | normalizeTag(newTag, normalize) 127 | ); 128 | changes.push({ oldTag: normalizedTag, newTag: updatedTag }); 129 | return updatedTag; 130 | } 131 | } 132 | 133 | return normalizedTag; 134 | }); 135 | 136 | updatedFrontmatter.tags = Array.from(new Set(updatedTags)).sort(); 137 | return { frontmatter: updatedFrontmatter, changes }; 138 | } 139 | 140 | /** 141 | * Updates inline tags in content 142 | */ 143 | function updateInlineTags( 144 | content: string, 145 | replacements: TagReplacement[], 146 | normalize: boolean 147 | ): { 148 | content: string; 149 | changes: { oldTag: string; newTag: string; line: number }[]; 150 | } { 151 | const changes: { oldTag: string; newTag: string; line: number }[] = []; 152 | const lines = content.split('\n'); 153 | let inCodeBlock = false; 154 | let inHtmlComment = false; 155 | 156 | const updatedLines = lines.map((line, lineNum) => { 157 | // Handle code blocks and comments 158 | if (line.trim().startsWith('```')) { 159 | inCodeBlock = !inCodeBlock; 160 | return line; 161 | } 162 | if (line.includes('<!--')) inHtmlComment = true; 163 | if (line.includes('-->')) inHtmlComment = false; 164 | if (inCodeBlock || inHtmlComment) return line; 165 | 166 | // Update tags in regular content 167 | return line.replace( 168 | /(?<!`)#[a-zA-Z0-9][a-zA-Z0-9/]*(?!`)/g, 169 | (match) => { 170 | const tag = match.slice(1); 171 | const normalizedTag = normalizeTag(tag, normalize); 172 | 173 | for (const { oldTag, newTag } of replacements) { 174 | const normalizedOldTag = normalizeTag(oldTag, normalize); 175 | 176 | if (normalizedTag === normalizedOldTag || 177 | normalizedTag.startsWith(normalizedOldTag + '/')) { 178 | const updatedTag = normalizedTag.replace( 179 | new RegExp(`^${normalizedOldTag}`), 180 | normalizeTag(newTag, normalize) 181 | ); 182 | changes.push({ 183 | oldTag: normalizedTag, 184 | newTag: updatedTag, 185 | line: lineNum + 1 186 | }); 187 | return `#${updatedTag}`; 188 | } 189 | } 190 | 191 | return match; 192 | } 193 | ); 194 | }); 195 | 196 | return { 197 | content: updatedLines.join('\n'), 198 | changes 199 | }; 200 | } 201 | 202 | /** 203 | * Updates saved searches and filters 204 | */ 205 | async function updateSavedSearches( 206 | vaultPath: string, 207 | replacements: TagReplacement[], 208 | normalize: boolean 209 | ): Promise<void> { 210 | const searchConfigPath = path.join(vaultPath, '.obsidian', 'search.json'); 211 | 212 | if (!await fileExists(searchConfigPath)) return; 213 | 214 | try { 215 | const searchConfig = JSON.parse( 216 | await fs.readFile(searchConfigPath, 'utf-8') 217 | ); 218 | 219 | let modified = false; 220 | 221 | // Update saved searches 222 | if (Array.isArray(searchConfig.savedSearches)) { 223 | searchConfig.savedSearches = searchConfig.savedSearches.map( 224 | (search: any) => { 225 | if (typeof search.query !== 'string') return search; 226 | 227 | let updatedQuery = search.query; 228 | for (const { oldTag, newTag } of replacements) { 229 | const normalizedOldTag = normalizeTag(oldTag, normalize); 230 | const normalizedNewTag = normalizeTag(newTag, normalize); 231 | 232 | // Update tag queries 233 | updatedQuery = updatedQuery.replace( 234 | new RegExp(`tag:${normalizedOldTag}(/\\S*)?`, 'g'), 235 | `tag:${normalizedNewTag}$1` 236 | ); 237 | 238 | // Update raw tag references 239 | updatedQuery = updatedQuery.replace( 240 | new RegExp(`#${normalizedOldTag}(/\\S*)?`, 'g'), 241 | `#${normalizedNewTag}$1` 242 | ); 243 | } 244 | 245 | if (updatedQuery !== search.query) { 246 | modified = true; 247 | return { ...search, query: updatedQuery }; 248 | } 249 | return search; 250 | } 251 | ); 252 | } 253 | 254 | if (modified) { 255 | await fs.writeFile( 256 | searchConfigPath, 257 | JSON.stringify(searchConfig, null, 2) 258 | ); 259 | } 260 | } catch (error) { 261 | console.error('Error updating saved searches:', error); 262 | // Continue with other operations 263 | } 264 | } 265 | 266 | /** 267 | * Processes files in batches to handle large vaults 268 | */ 269 | async function processBatch( 270 | files: string[], 271 | start: number, 272 | batchSize: number, 273 | replacements: TagReplacement[], 274 | normalize: boolean 275 | ): Promise<{ 276 | successful: TagChangeReport[]; 277 | failed: { filePath: string; error: string }[]; 278 | }> { 279 | const batch = files.slice(start, start + batchSize); 280 | const successful: TagChangeReport[] = []; 281 | const failed: { filePath: string; error: string }[] = []; 282 | 283 | await Promise.all( 284 | batch.map(async (filePath) => { 285 | try { 286 | const content = await safeReadFile(filePath); 287 | if (!content) { 288 | failed.push({ 289 | filePath, 290 | error: 'File not found or cannot be read' 291 | }); 292 | return; 293 | } 294 | 295 | const parsed = parseNote(content); 296 | 297 | // Update frontmatter tags 298 | const { frontmatter: updatedFrontmatter, changes: frontmatterChanges } = 299 | updateFrontmatterTags(parsed.frontmatter, replacements, normalize); 300 | 301 | // Update inline tags 302 | const { content: updatedContent, changes: contentChanges } = 303 | updateInlineTags(parsed.content, replacements, normalize); 304 | 305 | // Only write file if changes were made 306 | if (frontmatterChanges.length > 0 || contentChanges.length > 0) { 307 | const updatedNote = stringifyNote({ 308 | ...parsed, 309 | frontmatter: updatedFrontmatter, 310 | content: updatedContent 311 | }); 312 | 313 | await fs.writeFile(filePath, updatedNote, 'utf-8'); 314 | 315 | // Record changes 316 | if (frontmatterChanges.length > 0) { 317 | successful.push({ 318 | filePath, 319 | oldTags: frontmatterChanges.map(c => c.oldTag), 320 | newTags: frontmatterChanges.map(c => c.newTag), 321 | location: 'frontmatter' 322 | }); 323 | } 324 | 325 | if (contentChanges.length > 0) { 326 | successful.push({ 327 | filePath, 328 | oldTags: contentChanges.map(c => c.oldTag), 329 | newTags: contentChanges.map(c => c.newTag), 330 | location: 'content', 331 | line: contentChanges[0].line 332 | }); 333 | } 334 | } 335 | } catch (error) { 336 | failed.push({ 337 | filePath, 338 | error: error instanceof Error ? error.message : String(error) 339 | }); 340 | } 341 | }) 342 | ); 343 | 344 | return { successful, failed }; 345 | } 346 | 347 | /** 348 | * Renames tags throughout the vault while preserving hierarchies 349 | */ 350 | async function renameTag( 351 | vaultPath: string, 352 | params: Omit<RenameTagInput, 'vault'> 353 | ): Promise<RenameTagReport> { 354 | try { 355 | // Validate tags (though Zod schema already handles this) 356 | if (!validateTag(params.oldTag) || !validateTag(params.newTag)) { 357 | throw new McpError( 358 | ErrorCode.InvalidParams, 359 | 'Invalid tag format' 360 | ); 361 | } 362 | 363 | // Create backup if requested 364 | let backupPath: string | undefined; 365 | if (params.createBackup) { 366 | backupPath = await createVaultBackup(vaultPath); 367 | } 368 | 369 | // Get all markdown files 370 | const files = await getAllMarkdownFiles(vaultPath); 371 | 372 | // Process files in batches 373 | const successful: TagChangeReport[] = []; 374 | const failed: { filePath: string; error: string }[] = []; 375 | 376 | for (let i = 0; i < files.length; i += params.batchSize) { 377 | const { successful: batchSuccessful, failed: batchFailed } = 378 | await processBatch( 379 | files, 380 | i, 381 | params.batchSize, 382 | [{ oldTag: params.oldTag, newTag: params.newTag }], 383 | params.normalize 384 | ); 385 | 386 | successful.push(...batchSuccessful); 387 | failed.push(...batchFailed); 388 | } 389 | 390 | // Update saved searches 391 | await updateSavedSearches( 392 | vaultPath, 393 | [{ oldTag: params.oldTag, newTag: params.newTag }], 394 | params.normalize 395 | ); 396 | 397 | return { 398 | successful, 399 | failed, 400 | timestamp: new Date().toISOString(), 401 | backupCreated: backupPath 402 | }; 403 | } catch (error) { 404 | // Ensure errors are properly propagated 405 | if (error instanceof McpError) { 406 | throw error; 407 | } 408 | throw new McpError( 409 | ErrorCode.InternalError, 410 | error instanceof Error ? error.message : 'Unknown error during tag renaming' 411 | ); 412 | } 413 | } 414 | 415 | export function createRenameTagTool(vaults: Map<string, string>) { 416 | return createTool<RenameTagInput>({ 417 | name: 'rename-tag', 418 | description: `Safely renames tags throughout the vault while preserving hierarchies. 419 | 420 | Examples: 421 | - Simple rename: { "oldTag": "project", "newTag": "projects" } 422 | - Rename with hierarchy: { "oldTag": "work/active", "newTag": "projects/current" } 423 | - With options: { "oldTag": "status", "newTag": "state", "normalize": true, "createBackup": true } 424 | - INCORRECT: { "oldTag": "#project" } (don't include # symbol)`, 425 | schema, 426 | handler: async (args, vaultPath, _vaultName) => { 427 | const results = await renameTag(vaultPath, { 428 | oldTag: args.oldTag, 429 | newTag: args.newTag, 430 | createBackup: args.createBackup ?? true, 431 | normalize: args.normalize ?? true, 432 | batchSize: args.batchSize ?? 50 433 | }); 434 | 435 | // Format response message 436 | let message = ''; 437 | 438 | // Add backup info if created 439 | if (results.backupCreated) { 440 | message += `Created backup at: ${results.backupCreated}\n\n`; 441 | } 442 | 443 | // Add success summary 444 | if (results.successful.length > 0) { 445 | message += `Successfully renamed tags in ${results.successful.length} locations:\n\n`; 446 | 447 | // Group changes by file 448 | const changesByFile = results.successful.reduce((acc, change) => { 449 | if (!acc[change.filePath]) { 450 | acc[change.filePath] = []; 451 | } 452 | acc[change.filePath].push(change); 453 | return acc; 454 | }, {} as Record<string, typeof results.successful>); 455 | 456 | // Report changes for each file 457 | for (const [file, changes] of Object.entries(changesByFile)) { 458 | message += `${file}:\n`; 459 | changes.forEach(change => { 460 | const location = change.line 461 | ? `${change.location} (line ${change.line})` 462 | : change.location; 463 | message += ` ${location}: ${change.oldTags.join(', ')} -> ${change.newTags.join(', ')}\n`; 464 | }); 465 | message += '\n'; 466 | } 467 | } 468 | 469 | // Add errors if any 470 | if (results.failed.length > 0) { 471 | message += 'Errors:\n'; 472 | results.failed.forEach(error => { 473 | message += ` ${error.filePath}: ${error.error}\n`; 474 | }); 475 | } 476 | 477 | return { 478 | content: [{ 479 | type: 'text', 480 | text: message.trim() 481 | }] 482 | }; 483 | } 484 | }, vaults); 485 | } 486 | ``` -------------------------------------------------------------------------------- /src/utils/path.ts: -------------------------------------------------------------------------------- ```typescript 1 | import path from "path"; 2 | import fs from "fs/promises"; 3 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 4 | import os from "os"; 5 | import { exec as execCallback } from "child_process"; 6 | import { promisify } from "util"; 7 | 8 | // Promisify exec for cleaner async/await usage 9 | const exec = promisify(execCallback); 10 | 11 | /** 12 | * Checks if a path contains any problematic characters or patterns 13 | * @param vaultPath - The path to validate 14 | * @returns Error message if invalid, null if valid 15 | */ 16 | export function checkPathCharacters(vaultPath: string): string | null { 17 | // Platform-specific path length limits 18 | const maxPathLength = process.platform === 'win32' ? 260 : 4096; 19 | if (vaultPath.length > maxPathLength) { 20 | return `Path exceeds maximum length (${maxPathLength} characters)`; 21 | } 22 | 23 | // Check component length (individual parts between separators) 24 | const components = vaultPath.split(/[\/\\]/); 25 | const maxComponentLength = process.platform === 'win32' ? 255 : 255; 26 | const longComponent = components.find(c => c.length > maxComponentLength); 27 | if (longComponent) { 28 | return `Directory/file name too long: "${longComponent.slice(0, 50)}..."`; 29 | } 30 | 31 | // Check for root-only paths 32 | if (process.platform === 'win32') { 33 | if (/^[A-Za-z]:\\?$/.test(vaultPath)) { 34 | return 'Cannot use drive root directory'; 35 | } 36 | } else { 37 | if (vaultPath === '/') { 38 | return 'Cannot use filesystem root directory'; 39 | } 40 | } 41 | 42 | // Check for relative path components 43 | if (components.includes('..') || components.includes('.')) { 44 | return 'Path cannot contain relative components (. or ..)'; 45 | } 46 | 47 | // Check for non-printable characters 48 | if (/[\x00-\x1F\x7F]/.test(vaultPath)) { 49 | return 'Contains non-printable characters'; 50 | } 51 | 52 | // Platform-specific checks 53 | if (process.platform === 'win32') { 54 | // Windows-specific checks 55 | const winReservedNames = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i; 56 | const pathParts = vaultPath.split(/[\/\\]/); 57 | if (pathParts.some(part => winReservedNames.test(part))) { 58 | return 'Contains Windows reserved names (CON, PRN, etc.)'; 59 | } 60 | 61 | // Windows invalid characters (allowing : for drive letters) 62 | // First check if this is a Windows path with a drive letter 63 | if (/^[A-Za-z]:[\/\\]/.test(vaultPath)) { 64 | // Skip the drive letter part and check the rest of the path 65 | const pathWithoutDrive = vaultPath.slice(2); 66 | const components = pathWithoutDrive.split(/[\/\\]/); 67 | for (const part of components) { 68 | if (/[<>:"|?*]/.test(part)) { 69 | return 'Contains characters not allowed on Windows (<>:"|?*)'; 70 | } 71 | } 72 | } else { 73 | // No drive letter, check all components normally 74 | const components = vaultPath.split(/[\/\\]/); 75 | for (const part of components) { 76 | if (/[<>:"|?*]/.test(part)) { 77 | return 'Contains characters not allowed on Windows (<>:"|?*)'; 78 | } 79 | } 80 | } 81 | 82 | // Windows device paths 83 | if (/^\\\\.\\/.test(vaultPath)) { 84 | return 'Device paths are not allowed'; 85 | } 86 | } else { 87 | // Unix-specific checks 88 | const unixInvalidChars = /[\x00]/; // Only check for null character 89 | const pathComponents = vaultPath.split('/'); 90 | for (const component of pathComponents) { 91 | if (unixInvalidChars.test(component)) { 92 | return 'Contains invalid characters for Unix paths'; 93 | } 94 | } 95 | } 96 | 97 | // Check for Unicode replacement character 98 | if (vaultPath.includes('\uFFFD')) { 99 | return 'Contains invalid Unicode characters'; 100 | } 101 | 102 | // Check for leading/trailing whitespace 103 | if (vaultPath !== vaultPath.trim()) { 104 | return 'Contains leading or trailing whitespace'; 105 | } 106 | 107 | // Check for consecutive separators 108 | if (/[\/\\]{2,}/.test(vaultPath)) { 109 | return 'Contains consecutive path separators'; 110 | } 111 | 112 | return null; 113 | } 114 | 115 | /** 116 | * Checks if a path is on a local filesystem 117 | * @param vaultPath - The path to check 118 | * @returns Error message if invalid, null if valid 119 | */ 120 | export async function checkLocalPath(vaultPath: string): Promise<string | null> { 121 | try { 122 | // Get real path (resolves symlinks) 123 | const realPath = await fs.realpath(vaultPath); 124 | 125 | // Check if path changed significantly after resolving symlinks 126 | if (path.dirname(realPath) !== path.dirname(vaultPath)) { 127 | return 'Path contains symlinks that point outside the parent directory'; 128 | } 129 | 130 | // Check for network paths 131 | if (process.platform === 'win32') { 132 | // Windows UNC paths and mapped drives 133 | if (realPath.startsWith('\\\\') || /^[a-zA-Z]:\\$/.test(realPath.slice(0, 3))) { 134 | // Check Windows drive type 135 | const drive = realPath[0].toUpperCase(); 136 | 137 | // Helper functions for drive type checking 138 | async function checkWithWmic() { 139 | const cmd = `wmic logicaldisk where "DeviceID='${drive}:'" get DriveType /value`; 140 | return await exec(cmd, { timeout: 5000 }); 141 | } 142 | 143 | async function checkWithPowershell() { 144 | const cmd = `powershell -Command "(Get-WmiObject -Class Win32_LogicalDisk | Where-Object { $_.DeviceID -eq '${drive}:' }).DriveType"`; 145 | const { stdout, stderr } = await exec(cmd, { timeout: 5000 }); 146 | return { stdout: `DriveType=${stdout.trim()}`, stderr }; 147 | } 148 | 149 | try { 150 | let result: { stdout: string; stderr: string }; 151 | try { 152 | result = await checkWithWmic(); 153 | } catch (wmicError) { 154 | // Fallback to PowerShell if WMIC fails 155 | result = await checkWithPowershell(); 156 | } 157 | 158 | const { stdout, stderr } = result; 159 | 160 | if (stderr) { 161 | console.error(`Warning: Drive type check produced errors:`, stderr); 162 | } 163 | 164 | // DriveType: 2 = Removable, 3 = Local, 4 = Network, 5 = CD-ROM, 6 = RAM disk 165 | const match = stdout.match(/DriveType=(\d+)/); 166 | const driveType = match ? match[1] : '0'; 167 | 168 | // Consider removable drives and unknown types as potentially network-based 169 | if (driveType === '0' || driveType === '2' || driveType === '4') { 170 | return 'Network, removable, or unknown drive type is not supported'; 171 | } 172 | } catch (error: unknown) { 173 | if ((error as Error & { code?: string }).code === 'ETIMEDOUT') { 174 | return 'Network, removable, or unknown drive type is not supported'; 175 | } 176 | console.error(`Error checking drive type:`, error); 177 | // Fail safe: treat any errors as potential network drives 178 | return 'Unable to verify if drive is local'; 179 | } 180 | } 181 | } else { 182 | // Unix network mounts (common mount points) 183 | const networkPaths = ['/net/', '/mnt/', '/media/', '/Volumes/']; 184 | if (networkPaths.some(prefix => realPath.startsWith(prefix))) { 185 | // Check if it's a network mount using df 186 | // Check Unix mount type 187 | const cmd = `df -P "${realPath}" | tail -n 1`; 188 | try { 189 | const { stdout, stderr } = await exec(cmd, { timeout: 5000 }) 190 | .catch((error: Error & { code?: string }) => { 191 | if (error.code === 'ETIMEDOUT') { 192 | // Timeout often indicates a network mount 193 | return { stdout: 'network', stderr: '' }; 194 | } 195 | throw error; 196 | }); 197 | 198 | if (stderr) { 199 | console.error(`Warning: Mount type check produced errors:`, stderr); 200 | } 201 | 202 | // Check for common network filesystem indicators 203 | const isNetwork = stdout.match(/^(nfs|cifs|smb|afp|ftp|ssh|davfs)/i) || 204 | stdout.includes(':') || 205 | stdout.includes('//') || 206 | stdout.includes('type fuse.') || 207 | stdout.includes('network'); 208 | 209 | if (isNetwork) { 210 | return 'Network or remote filesystem is not supported'; 211 | } 212 | } catch (error: unknown) { 213 | console.error(`Error checking mount type:`, error); 214 | // Fail safe: treat any errors as potential network mounts 215 | return 'Unable to verify if filesystem is local'; 216 | } 217 | } 218 | } 219 | 220 | return null; 221 | } catch (error) { 222 | if ((error as NodeJS.ErrnoException).code === 'ELOOP') { 223 | return 'Contains circular symlinks'; 224 | } 225 | return null; // Other errors will be caught by the main validation 226 | } 227 | } 228 | 229 | /** 230 | * Checks if a path contains any suspicious patterns 231 | * @param vaultPath - The path to check 232 | * @returns Error message if suspicious, null if valid 233 | */ 234 | export async function checkSuspiciousPath(vaultPath: string): Promise<string | null> { 235 | // Check for hidden directories (except .obsidian) 236 | if (vaultPath.split(path.sep).some(part => 237 | part.startsWith('.') && part !== '.obsidian')) { 238 | return 'Contains hidden directories'; 239 | } 240 | 241 | // Check for system directories 242 | const systemDirs = [ 243 | '/bin', '/sbin', '/usr/bin', '/usr/sbin', 244 | '/etc', '/var', '/tmp', '/dev', '/sys', 245 | 'C:\\Windows', 'C:\\Program Files', 'C:\\System32', 246 | 'C:\\Users\\All Users', 'C:\\ProgramData' 247 | ]; 248 | if (systemDirs.some(dir => vaultPath.toLowerCase().startsWith(dir.toLowerCase()))) { 249 | return 'Points to a system directory'; 250 | } 251 | 252 | // Check for home directory root (too broad access) 253 | if (vaultPath === os.homedir()) { 254 | return 'Points to home directory root'; 255 | } 256 | 257 | // Check for path length 258 | if (vaultPath.length > 255) { 259 | return 'Path is too long (maximum 255 characters)'; 260 | } 261 | 262 | // Check for problematic characters 263 | const charIssue = checkPathCharacters(vaultPath); 264 | if (charIssue) { 265 | return charIssue; 266 | } 267 | 268 | return null; 269 | } 270 | 271 | /** 272 | * Normalizes and resolves a path consistently 273 | * @param inputPath - The path to normalize 274 | * @returns The normalized and resolved absolute path 275 | * @throws {McpError} If the input path is empty or invalid 276 | */ 277 | export function normalizePath(inputPath: string): string { 278 | if (!inputPath || typeof inputPath !== "string") { 279 | throw new McpError( 280 | ErrorCode.InvalidRequest, 281 | `Invalid path: ${inputPath}` 282 | ); 283 | } 284 | 285 | try { 286 | // Handle Windows paths 287 | let normalized = inputPath; 288 | 289 | // Only validate filename portion for invalid Windows characters, allowing : for drive letters 290 | const filename = normalized.split(/[\\/]/).pop() || ''; 291 | if (/[<>"|?*]/.test(filename) || (/:/.test(filename) && !/^[A-Za-z]:$/.test(filename))) { 292 | throw new McpError( 293 | ErrorCode.InvalidRequest, 294 | `Filename contains invalid characters: ${filename}` 295 | ); 296 | } 297 | 298 | // Preserve UNC paths 299 | if (normalized.startsWith('\\\\')) { 300 | // Convert to forward slashes but preserve exactly two leading slashes 301 | normalized = '//' + normalized.slice(2).replace(/\\/g, '/'); 302 | return normalized; 303 | } 304 | 305 | // Handle Windows drive letters 306 | if (/^[a-zA-Z]:[\\/]/.test(normalized)) { 307 | // Normalize path while preserving drive letter 308 | normalized = path.normalize(normalized); 309 | // Convert to forward slashes for consistency 310 | normalized = normalized.replace(/\\/g, '/'); 311 | return normalized; 312 | } 313 | 314 | // Only restrict critical system directories 315 | const restrictedDirs = [ 316 | 'C:\\Windows', 317 | 'C:\\Program Files', 318 | 'C:\\Program Files (x86)', 319 | 'C:\\ProgramData' 320 | ]; 321 | if (restrictedDirs.some(dir => normalized.toLowerCase().startsWith(dir.toLowerCase()))) { 322 | throw new McpError( 323 | ErrorCode.InvalidRequest, 324 | `Path points to restricted system directory: ${normalized}` 325 | ); 326 | } 327 | 328 | // Handle relative paths 329 | if (normalized.startsWith('./') || normalized.startsWith('../')) { 330 | normalized = path.normalize(normalized); 331 | return path.resolve(normalized); 332 | } 333 | 334 | // Default normalization for other paths 335 | normalized = normalized.replace(/\\/g, '/'); 336 | if (normalized.startsWith('./') || normalized.startsWith('../')) { 337 | return path.resolve(normalized); 338 | } 339 | return normalized; 340 | } catch (error) { 341 | throw new McpError( 342 | ErrorCode.InvalidRequest, 343 | `Failed to normalize path: ${error instanceof Error ? error.message : String(error)}` 344 | ); 345 | } 346 | } 347 | 348 | /** 349 | * Checks if a target path is safely contained within a base path 350 | * @param basePath - The base directory path 351 | * @param targetPath - The target path to check 352 | * @returns True if target is within base path, false otherwise 353 | */ 354 | export async function checkPathSafety(basePath: string, targetPath: string): Promise<boolean> { 355 | const resolvedPath = normalizePath(targetPath); 356 | const resolvedBasePath = normalizePath(basePath); 357 | 358 | try { 359 | // Check real path for symlinks 360 | const realPath = await fs.realpath(resolvedPath); 361 | const normalizedReal = normalizePath(realPath); 362 | 363 | // Check if real path is within base path 364 | if (!normalizedReal.startsWith(resolvedBasePath)) { 365 | return false; 366 | } 367 | 368 | // Check if original path is within base path 369 | return resolvedPath.startsWith(resolvedBasePath); 370 | } catch (error) { 371 | // For new files that don't exist yet, verify parent directory 372 | const parentDir = path.dirname(resolvedPath); 373 | try { 374 | const realParentPath = await fs.realpath(parentDir); 375 | const normalizedParent = normalizePath(realParentPath); 376 | return normalizedParent.startsWith(resolvedBasePath); 377 | } catch { 378 | return false; 379 | } 380 | } 381 | } 382 | 383 | /** 384 | * Ensures a path has .md extension and is valid 385 | * @param filePath - The file path to check 386 | * @returns The path with .md extension 387 | * @throws {McpError} If the path is invalid 388 | */ 389 | export function ensureMarkdownExtension(filePath: string): string { 390 | const normalized = normalizePath(filePath); 391 | return normalized.endsWith('.md') ? normalized : `${normalized}.md`; 392 | } 393 | 394 | /** 395 | * Validates that a path is within the vault directory 396 | * @param vaultPath - The vault directory path 397 | * @param targetPath - The target path to validate 398 | * @throws {McpError} If path is outside vault or invalid 399 | */ 400 | export function validateVaultPath(vaultPath: string, targetPath: string): void { 401 | if (!checkPathSafety(vaultPath, targetPath)) { 402 | throw new McpError( 403 | ErrorCode.InvalidRequest, 404 | `Path must be within the vault directory. Path: ${targetPath}, Vault: ${vaultPath}` 405 | ); 406 | } 407 | } 408 | 409 | /** 410 | * Safely joins paths and ensures result is within vault 411 | * @param vaultPath - The vault directory path 412 | * @param segments - Path segments to join 413 | * @returns The joined and validated path 414 | * @throws {McpError} If resulting path would be outside vault 415 | */ 416 | export function safeJoinPath(vaultPath: string, ...segments: string[]): string { 417 | const joined = path.join(vaultPath, ...segments); 418 | const resolved = normalizePath(joined); 419 | 420 | validateVaultPath(vaultPath, resolved); 421 | 422 | return resolved; 423 | } 424 | 425 | /** 426 | * Sanitizes a vault name to be filesystem-safe 427 | * @param name - The raw vault name 428 | * @returns The sanitized vault name 429 | */ 430 | export function sanitizeVaultName(name: string): string { 431 | return name 432 | .toLowerCase() 433 | // Replace spaces and special characters with hyphens 434 | .replace(/[^a-z0-9]+/g, '-') 435 | // Remove leading/trailing hyphens 436 | .replace(/^-+|-+$/g, '') 437 | // Ensure name isn't empty 438 | || 'unnamed-vault'; 439 | } 440 | 441 | /** 442 | * Checks if one path is a parent of another 443 | * @param parent - The potential parent path 444 | * @param child - The potential child path 445 | * @returns True if parent contains child, false otherwise 446 | */ 447 | export function isParentPath(parent: string, child: string): boolean { 448 | const relativePath = path.relative(parent, child); 449 | return !relativePath.startsWith('..') && !path.isAbsolute(relativePath); 450 | } 451 | 452 | /** 453 | * Checks if paths overlap or are duplicates 454 | * @param paths - Array of paths to check 455 | * @throws {McpError} If paths overlap or are duplicates 456 | */ 457 | export function checkPathOverlap(paths: string[]): void { 458 | // First normalize all paths to handle . and .. and symlinks 459 | const normalizedPaths = paths.map(p => { 460 | // Remove trailing slashes and normalize separators 461 | return path.normalize(p).replace(/[\/\\]+$/, ''); 462 | }); 463 | 464 | // Check for exact duplicates using normalized paths 465 | const uniquePaths = new Set<string>(); 466 | normalizedPaths.forEach((normalizedPath, index) => { 467 | if (uniquePaths.has(normalizedPath)) { 468 | throw new McpError( 469 | ErrorCode.InvalidRequest, 470 | `Duplicate vault path provided:\n` + 471 | ` Original paths:\n` + 472 | ` 1: ${paths[index]}\n` + 473 | ` 2: ${paths[normalizedPaths.indexOf(normalizedPath)]}\n` + 474 | ` Both resolve to: ${normalizedPath}` 475 | ); 476 | } 477 | uniquePaths.add(normalizedPath); 478 | }); 479 | 480 | // Then check for overlapping paths using normalized paths 481 | for (let i = 0; i < normalizedPaths.length; i++) { 482 | for (let j = i + 1; j < normalizedPaths.length; j++) { 483 | if (isParentPath(normalizedPaths[i], normalizedPaths[j]) || 484 | isParentPath(normalizedPaths[j], normalizedPaths[i])) { 485 | throw new McpError( 486 | ErrorCode.InvalidRequest, 487 | `Vault paths cannot overlap:\n` + 488 | ` Path 1: ${paths[i]}\n` + 489 | ` Path 2: ${paths[j]}\n` + 490 | ` (One vault directory cannot be inside another)\n` + 491 | ` Normalized paths:\n` + 492 | ` 1: ${normalizedPaths[i]}\n` + 493 | ` 2: ${normalizedPaths[j]}` 494 | ); 495 | } 496 | } 497 | } 498 | } 499 | ``` -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | import { ObsidianServer } from "./server.js"; 3 | import { createCreateNoteTool } from "./tools/create-note/index.js"; 4 | import { createListAvailableVaultsTool } from "./tools/list-available-vaults/index.js"; 5 | import { createEditNoteTool } from "./tools/edit-note/index.js"; 6 | import { createSearchVaultTool } from "./tools/search-vault/index.js"; 7 | import { createMoveNoteTool } from "./tools/move-note/index.js"; 8 | import { createCreateDirectoryTool } from "./tools/create-directory/index.js"; 9 | import { createDeleteNoteTool } from "./tools/delete-note/index.js"; 10 | import { createAddTagsTool } from "./tools/add-tags/index.js"; 11 | import { createRemoveTagsTool } from "./tools/remove-tags/index.js"; 12 | import { createRenameTagTool } from "./tools/rename-tag/index.js"; 13 | import { createReadNoteTool } from "./tools/read-note/index.js"; 14 | import { listVaultsPrompt } from "./prompts/list-vaults/index.js"; 15 | import { registerPrompt } from "./utils/prompt-factory.js"; 16 | import path from "path"; 17 | import os from "os"; 18 | import { promises as fs, constants as fsConstants } from "fs"; 19 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 20 | import { 21 | checkPathCharacters, 22 | checkLocalPath, 23 | checkSuspiciousPath, 24 | sanitizeVaultName, 25 | checkPathOverlap 26 | } from "./utils/path.js"; 27 | 28 | interface VaultConfig { 29 | name: string; 30 | path: string; 31 | } 32 | 33 | async function main() { 34 | // Constants 35 | const MAX_VAULTS = 10; // Reasonable limit to prevent resource issues 36 | 37 | const vaultArgs = process.argv.slice(2); 38 | if (vaultArgs.length === 0) { 39 | const helpMessage = ` 40 | Obsidian MCP Server - Multi-vault Support 41 | 42 | Usage: obsidian-mcp <vault1_path> [vault2_path ...] 43 | 44 | Requirements: 45 | - Paths must point to valid Obsidian vaults (containing .obsidian directory) 46 | - Vaults must be initialized in Obsidian at least once 47 | - Paths must have read and write permissions 48 | - Paths cannot overlap (one vault cannot be inside another) 49 | - Each vault must be a separate directory 50 | - Maximum ${MAX_VAULTS} vaults can be connected at once 51 | 52 | Security restrictions: 53 | - Must be on a local filesystem (no network drives or mounts) 54 | - Cannot point to system directories 55 | - Hidden directories not allowed (except .obsidian) 56 | - Cannot use the home directory root 57 | - Cannot use symlinks that point outside their directory 58 | - All paths must be dedicated vault directories 59 | 60 | Note: If a path is not recognized as a vault, open it in Obsidian first to 61 | initialize it properly. This creates the required .obsidian configuration directory. 62 | 63 | Recommended locations: 64 | - ~/Documents/Obsidian/[vault-name] # Recommended for most users 65 | - ~/Notes/[vault-name] # Alternative location 66 | - ~/Obsidian/[vault-name] # Alternative location 67 | 68 | Not supported: 69 | - Network drives (//server/share) 70 | - Network mounts (/net, /mnt, /media) 71 | - System directories (/tmp, C:\\Windows) 72 | - Hidden directories (except .obsidian) 73 | 74 | Vault names are automatically generated from the last part of each path: 75 | - Spaces and special characters are converted to hyphens 76 | - Names are made lowercase for consistency 77 | - Numbers are appended to resolve duplicates (e.g., 'work-vault-1') 78 | 79 | Examples: 80 | # Valid paths: 81 | obsidian-mcp ~/Documents/Obsidian/Work ~/Documents/Obsidian/Personal 82 | → Creates vaults named 'work' and 'personal' 83 | 84 | obsidian-mcp ~/Notes/Work ~/Notes/Archive 85 | → Creates vaults named 'work' and 'archive' 86 | 87 | # Invalid paths: 88 | obsidian-mcp ~/Vaults ~/Vaults/Work # ❌ Paths overlap 89 | obsidian-mcp ~/Work ~/Work # ❌ Duplicate paths 90 | obsidian-mcp ~/ # ❌ Home directory root 91 | obsidian-mcp /tmp/vault # ❌ System directory 92 | obsidian-mcp ~/.config/vault # ❌ Hidden directory 93 | obsidian-mcp //server/share/vault # ❌ Network path 94 | obsidian-mcp /mnt/network/vault # ❌ Network mount 95 | obsidian-mcp ~/symlink-to-vault # ❌ External symlink 96 | `; 97 | 98 | // Log help message to stderr for user reference 99 | console.error(helpMessage); 100 | 101 | // Write MCP error to stdout 102 | process.stdout.write(JSON.stringify({ 103 | jsonrpc: "2.0", 104 | error: { 105 | code: ErrorCode.InvalidRequest, 106 | message: "No vault paths provided. Please provide at least one valid Obsidian vault path." 107 | }, 108 | id: null 109 | })); 110 | 111 | process.exit(1); 112 | } 113 | 114 | // Validate and normalize vault paths 115 | const normalizedPaths = await Promise.all(vaultArgs.map(async (vaultPath, index) => { 116 | try { 117 | // Expand home directory if needed 118 | const expandedPath = vaultPath.startsWith('~') ? 119 | path.join(os.homedir(), vaultPath.slice(1)) : 120 | vaultPath; 121 | 122 | // Normalize and convert to absolute path 123 | const normalizedPath = path.normalize(expandedPath) 124 | .replace(/[\/\\]+$/, ''); // Remove trailing slashes 125 | const absolutePath = path.resolve(normalizedPath); 126 | 127 | // Validate path is absolute and safe 128 | if (!path.isAbsolute(absolutePath)) { 129 | const errorMessage = `Vault path must be absolute: ${vaultPath}`; 130 | console.error(`Error: ${errorMessage}`); 131 | 132 | process.stdout.write(JSON.stringify({ 133 | jsonrpc: "2.0", 134 | error: { 135 | code: ErrorCode.InvalidRequest, 136 | message: errorMessage 137 | }, 138 | id: null 139 | })); 140 | 141 | process.exit(1); 142 | } 143 | 144 | // Check for suspicious paths and local filesystem 145 | const [suspiciousReason, localPathIssue] = await Promise.all([ 146 | checkSuspiciousPath(absolutePath), 147 | checkLocalPath(absolutePath) 148 | ]); 149 | 150 | if (localPathIssue) { 151 | const errorMessage = `Invalid vault path (${localPathIssue}): ${vaultPath}\n` + 152 | `For reliability and security reasons, vault paths must:\n` + 153 | `- Be on a local filesystem\n` + 154 | `- Not use network drives or mounts\n` + 155 | `- Not contain symlinks that point outside their directory`; 156 | 157 | console.error(`Error: ${errorMessage}`); 158 | 159 | process.stdout.write(JSON.stringify({ 160 | jsonrpc: "2.0", 161 | error: { 162 | code: ErrorCode.InvalidRequest, 163 | message: errorMessage 164 | }, 165 | id: null 166 | })); 167 | 168 | process.exit(1); 169 | } 170 | 171 | if (suspiciousReason) { 172 | const errorMessage = `Invalid vault path (${suspiciousReason}): ${vaultPath}\n` + 173 | `For security reasons, vault paths cannot:\n` + 174 | `- Point to system directories\n` + 175 | `- Use hidden directories (except .obsidian)\n` + 176 | `- Point to the home directory root\n` + 177 | `Please choose a dedicated directory for your vault`; 178 | 179 | console.error(`Error: ${errorMessage}`); 180 | 181 | process.stdout.write(JSON.stringify({ 182 | jsonrpc: "2.0", 183 | error: { 184 | code: ErrorCode.InvalidRequest, 185 | message: errorMessage 186 | }, 187 | id: null 188 | })); 189 | 190 | process.exit(1); 191 | } 192 | 193 | try { 194 | // Check if path exists and is a directory 195 | const stats = await fs.stat(absolutePath); 196 | if (!stats.isDirectory()) { 197 | const errorMessage = `Vault path must be a directory: ${vaultPath}`; 198 | console.error(`Error: ${errorMessage}`); 199 | 200 | process.stdout.write(JSON.stringify({ 201 | jsonrpc: "2.0", 202 | error: { 203 | code: ErrorCode.InvalidRequest, 204 | message: errorMessage 205 | }, 206 | id: null 207 | })); 208 | 209 | process.exit(1); 210 | } 211 | 212 | // Check if path is readable and writable 213 | await fs.access(absolutePath, fsConstants.R_OK | fsConstants.W_OK); 214 | 215 | // Check if this is a valid Obsidian vault 216 | const obsidianConfigPath = path.join(absolutePath, '.obsidian'); 217 | const obsidianAppConfigPath = path.join(obsidianConfigPath, 'app.json'); 218 | 219 | try { 220 | // Check .obsidian directory 221 | const configStats = await fs.stat(obsidianConfigPath); 222 | if (!configStats.isDirectory()) { 223 | const errorMessage = `Invalid Obsidian vault configuration in ${vaultPath}\n` + 224 | `The .obsidian folder exists but is not a directory\n` + 225 | `Try removing it and reopening the vault in Obsidian`; 226 | 227 | console.error(`Error: ${errorMessage}`); 228 | 229 | process.stdout.write(JSON.stringify({ 230 | jsonrpc: "2.0", 231 | error: { 232 | code: ErrorCode.InvalidRequest, 233 | message: errorMessage 234 | }, 235 | id: null 236 | })); 237 | 238 | process.exit(1); 239 | } 240 | 241 | // Check app.json to verify it's properly initialized 242 | await fs.access(obsidianAppConfigPath, fsConstants.R_OK); 243 | 244 | } catch (error) { 245 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 246 | const errorMessage = `Not a valid Obsidian vault (${vaultPath})\n` + 247 | `Missing or incomplete .obsidian configuration\n\n` + 248 | `To fix this:\n` + 249 | `1. Open Obsidian\n` + 250 | `2. Click "Open folder as vault"\n` + 251 | `3. Select the directory: ${absolutePath}\n` + 252 | `4. Wait for Obsidian to initialize the vault\n` + 253 | `5. Try running this command again`; 254 | 255 | console.error(`Error: ${errorMessage}`); 256 | 257 | process.stdout.write(JSON.stringify({ 258 | jsonrpc: "2.0", 259 | error: { 260 | code: ErrorCode.InvalidRequest, 261 | message: errorMessage 262 | }, 263 | id: null 264 | })); 265 | } else { 266 | const errorMessage = `Error checking Obsidian configuration in ${vaultPath}: ${error instanceof Error ? error.message : String(error)}`; 267 | console.error(`Error: ${errorMessage}`); 268 | 269 | process.stdout.write(JSON.stringify({ 270 | jsonrpc: "2.0", 271 | error: { 272 | code: ErrorCode.InternalError, 273 | message: errorMessage 274 | }, 275 | id: null 276 | })); 277 | } 278 | process.exit(1); 279 | } 280 | 281 | return absolutePath; 282 | } catch (error) { 283 | let errorMessage: string; 284 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 285 | errorMessage = `Vault directory does not exist: ${vaultPath}`; 286 | } else if ((error as NodeJS.ErrnoException).code === 'EACCES') { 287 | errorMessage = `No permission to access vault directory: ${vaultPath}`; 288 | } else { 289 | errorMessage = `Error accessing vault path ${vaultPath}: ${error instanceof Error ? error.message : String(error)}`; 290 | } 291 | 292 | console.error(`Error: ${errorMessage}`); 293 | 294 | process.stdout.write(JSON.stringify({ 295 | jsonrpc: "2.0", 296 | error: { 297 | code: ErrorCode.InvalidRequest, 298 | message: errorMessage 299 | }, 300 | id: null 301 | })); 302 | 303 | process.exit(1); 304 | } 305 | } catch (error) { 306 | const errorMessage = `Error processing vault path ${vaultPath}: ${error instanceof Error ? error.message : String(error)}`; 307 | console.error(`Error: ${errorMessage}`); 308 | 309 | process.stdout.write(JSON.stringify({ 310 | jsonrpc: "2.0", 311 | error: { 312 | code: ErrorCode.InternalError, 313 | message: errorMessage 314 | }, 315 | id: null 316 | })); 317 | 318 | process.exit(1); 319 | } 320 | })); 321 | 322 | // Validate number of vaults 323 | if (vaultArgs.length > MAX_VAULTS) { 324 | const errorMessage = `Too many vaults specified (${vaultArgs.length})\n` + 325 | `Maximum number of vaults allowed: ${MAX_VAULTS}\n` + 326 | `This limit helps prevent performance issues and resource exhaustion`; 327 | 328 | console.error(`Error: ${errorMessage}`); 329 | 330 | process.stdout.write(JSON.stringify({ 331 | jsonrpc: "2.0", 332 | error: { 333 | code: ErrorCode.InvalidRequest, 334 | message: errorMessage 335 | }, 336 | id: null 337 | })); 338 | 339 | process.exit(1); 340 | } 341 | 342 | console.error(`Validating ${vaultArgs.length} vault path${vaultArgs.length > 1 ? 's' : ''}...`); 343 | 344 | // Check if we have any valid paths 345 | if (normalizedPaths.length === 0) { 346 | const errorMessage = `No valid vault paths provided\n` + 347 | `Make sure at least one path points to a valid Obsidian vault`; 348 | 349 | console.error(`\nError: ${errorMessage}`); 350 | 351 | process.stdout.write(JSON.stringify({ 352 | jsonrpc: "2.0", 353 | error: { 354 | code: ErrorCode.InvalidRequest, 355 | message: errorMessage 356 | }, 357 | id: null 358 | })); 359 | 360 | process.exit(1); 361 | } else if (normalizedPaths.length < vaultArgs.length) { 362 | console.error(`\nWarning: Only ${normalizedPaths.length} out of ${vaultArgs.length} paths were valid`); 363 | console.error("Some vaults will not be available"); 364 | } 365 | 366 | try { 367 | // Check for overlapping vault paths 368 | checkPathOverlap(normalizedPaths); 369 | } catch (error) { 370 | const errorMessage = error instanceof McpError ? error.message : String(error); 371 | console.error(`Error: ${errorMessage}`); 372 | 373 | process.stdout.write(JSON.stringify({ 374 | jsonrpc: "2.0", 375 | error: { 376 | code: ErrorCode.InvalidRequest, 377 | message: errorMessage 378 | }, 379 | id: null 380 | })); 381 | 382 | process.exit(1); 383 | } 384 | 385 | // Create vault configurations with human-friendly names 386 | console.error("\nInitializing vaults..."); 387 | const vaults: VaultConfig[] = normalizedPaths.map(vaultPath => { 388 | // Get the last directory name from the path as the vault name 389 | const rawName = path.basename(vaultPath); 390 | const vaultName = sanitizeVaultName(rawName); 391 | 392 | // Log the vault name mapping for user reference 393 | console.error(`Vault "${rawName}" registered as "${vaultName}"`); 394 | 395 | return { 396 | name: vaultName, 397 | path: vaultPath 398 | }; 399 | }); 400 | 401 | // Ensure vault names are unique by appending numbers if needed 402 | const uniqueVaults: VaultConfig[] = []; 403 | const usedNames = new Set<string>(); 404 | 405 | vaults.forEach(vault => { 406 | let uniqueName = vault.name; 407 | let counter = 1; 408 | 409 | // If name is already used, find a unique variant 410 | if (usedNames.has(uniqueName)) { 411 | console.error(`Note: Found duplicate vault name "${uniqueName}"`); 412 | while (usedNames.has(uniqueName)) { 413 | uniqueName = `${vault.name}-${counter}`; 414 | counter++; 415 | } 416 | console.error(` → Using "${uniqueName}" instead`); 417 | } 418 | 419 | usedNames.add(uniqueName); 420 | uniqueVaults.push({ 421 | name: uniqueName, 422 | path: vault.path 423 | }); 424 | }); 425 | 426 | // Log final vault configuration to stderr 427 | console.error("\nSuccessfully configured vaults:"); 428 | uniqueVaults.forEach(vault => { 429 | console.error(`- ${vault.name}`); 430 | console.error(` Path: ${vault.path}`); 431 | }); 432 | console.error(`\nTotal vaults: ${uniqueVaults.length}`); 433 | console.error(""); // Empty line for readability 434 | 435 | try { 436 | if (uniqueVaults.length === 0) { 437 | throw new McpError( 438 | ErrorCode.InvalidRequest, 439 | 'No valid Obsidian vaults provided. Please provide at least one valid vault path.\n\n' + 440 | 'Example usage:\n' + 441 | ' obsidian-mcp ~/Documents/Obsidian/MyVault\n\n' + 442 | 'The vault directory must:\n' + 443 | '- Exist and be accessible\n' + 444 | '- Contain a .obsidian directory (initialize by opening in Obsidian first)\n' + 445 | '- Have read/write permissions' 446 | ); 447 | } 448 | 449 | console.error(`Starting Obsidian MCP Server with ${uniqueVaults.length} vault${uniqueVaults.length > 1 ? 's' : ''}...`); 450 | 451 | const server = new ObsidianServer(uniqueVaults); 452 | console.error("Server initialized successfully"); 453 | 454 | // Handle graceful shutdown 455 | let isShuttingDown = false; 456 | async function shutdown(signal: string) { 457 | if (isShuttingDown) return; 458 | isShuttingDown = true; 459 | 460 | console.error(`\nReceived ${signal}, shutting down...`); 461 | try { 462 | await server.stop(); 463 | console.error("Server stopped cleanly"); 464 | process.exit(0); 465 | } catch (error) { 466 | console.error("Error during shutdown:", error); 467 | process.exit(1); 468 | } 469 | } 470 | 471 | // Register signal handlers 472 | process.on('SIGINT', () => shutdown('SIGINT')); // Ctrl+C 473 | process.on('SIGTERM', () => shutdown('SIGTERM')); // Kill command 474 | 475 | // Create vaults Map from unique vaults 476 | const vaultsMap = new Map(uniqueVaults.map(v => [v.name, v.path])); 477 | 478 | // Register tools with unique vault names 479 | const tools = [ 480 | createCreateNoteTool(vaultsMap), 481 | createListAvailableVaultsTool(vaultsMap), 482 | createEditNoteTool(vaultsMap), 483 | createSearchVaultTool(vaultsMap), 484 | createMoveNoteTool(vaultsMap), 485 | createCreateDirectoryTool(vaultsMap), 486 | createDeleteNoteTool(vaultsMap), 487 | createAddTagsTool(vaultsMap), 488 | createRemoveTagsTool(vaultsMap), 489 | createRenameTagTool(vaultsMap), 490 | createReadNoteTool(vaultsMap) 491 | ]; 492 | 493 | for (const tool of tools) { 494 | try { 495 | server.registerTool(tool); 496 | } catch (error) { 497 | console.error(`Error registering tool ${tool.name}:`, error); 498 | throw error; 499 | } 500 | } 501 | 502 | // All prompts are registered in the server constructor 503 | console.error("All tools registered successfully"); 504 | console.error("Server starting...\n"); 505 | 506 | // Start the server without logging to stdout 507 | await server.start(); 508 | } catch (error) { 509 | console.log(error instanceof Error ? error.message : String(error)); 510 | // Format error for MCP protocol 511 | const mcpError = error instanceof McpError ? error : new McpError( 512 | ErrorCode.InternalError, 513 | error instanceof Error ? error.message : String(error) 514 | ); 515 | 516 | // Write error in MCP protocol format to stdout 517 | process.stdout.write(JSON.stringify({ 518 | jsonrpc: "2.0", 519 | error: { 520 | code: mcpError.code, 521 | message: mcpError.message 522 | }, 523 | id: null 524 | })); 525 | 526 | // Log details to stderr for debugging 527 | console.error("\nFatal error starting server:"); 528 | console.error(mcpError.message); 529 | if (error instanceof Error && error.stack) { 530 | console.error("\nStack trace:"); 531 | console.error(error.stack.split('\n').slice(1).join('\n')); 532 | } 533 | 534 | process.exit(1); 535 | } 536 | } 537 | 538 | main().catch((error) => { 539 | console.error("Unhandled error:", error); 540 | process.exit(1); 541 | }); 542 | ```