#
tokens: 19103/50000 4/40 files (page 2/2)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 2/2FirstPrevNextLast