obsidian-mcp-server 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@
4
4
  import { Resource, TextContent } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { ObsidianClient } from "../obsidian/client.js";
6
6
  import { JsonLogicQuery } from "../obsidian/types.js";
7
+ import pLimit from 'p-limit'; // Import p-limit
7
8
  import { PropertyManager } from "../tools/properties/manager.js";
8
9
  import { join, sep } from "path";
9
10
  import { createLogger, ErrorCategoryType } from "../utils/logging.js";
@@ -40,6 +41,7 @@ export class TagResource {
40
41
  private tagCache: Map<string, Set<string>> = new Map();
41
42
  private propertyManager: PropertyManager;
42
43
  private isInitialized = false;
44
+ private isUpdating = false; // Flag to prevent concurrent updates
43
45
  private lastUpdate = 0;
44
46
  private updateInterval = 5000; // 5 seconds
45
47
 
@@ -77,24 +79,46 @@ export class TagResource {
77
79
  const results = await this.client.searchJson(query);
78
80
  this.tagCache.clear();
79
81
 
80
- // Process each file
81
- for (const result of results) {
82
- if (!('filename' in result)) continue;
83
-
84
- try {
85
- const content = await this.client.getFileContents(result.filename);
86
-
87
- // Only extract tags from frontmatter YAML
88
- const properties = this.propertyManager.parseProperties(content);
89
- if (properties.tags) {
90
- properties.tags.forEach((tag: string) => {
91
- this.addTag(tag, result.filename);
82
+ // Create a limiter with a concurrency of 3 (reduced from 10)
83
+ const limit = pLimit(3);
84
+
85
+ // Create promises for processing each file with concurrency limiting
86
+ const processingPromises = results
87
+ .filter(result => 'filename' in result) // Ensure filename exists
88
+ .map((result) => limit(async () => { // Wrap the async function with the limiter
89
+ const filename = result.filename;
90
+ try {
91
+ // This call is now rate-limited
92
+ const content = await this.client.getFileContents(filename);
93
+ // Only extract tags from frontmatter YAML
94
+ const properties = this.propertyManager.parseProperties(content);
95
+ return { filename, tags: properties.tags || [] };
96
+ } catch (error) {
97
+ logger.error(`Failed to process file ${filename}:`, errorToObject(error));
98
+ return { filename, error: true }; // Mark as failed
99
+ }
100
+ })); // Close the limiter wrapper
101
+
102
+ // Execute promises in parallel and wait for all to settle
103
+ const processedResults = await Promise.allSettled(processingPromises);
104
+
105
+ // Populate the cache from settled results
106
+ processedResults.forEach(settledResult => {
107
+ // Check if the promise was fulfilled and didn't encounter a processing error
108
+ if (settledResult.status === 'fulfilled' && !settledResult.value.error) {
109
+ const { filename, tags } = settledResult.value;
110
+ // Ensure tags is an array before iterating
111
+ if (tags && Array.isArray(tags)) {
112
+ tags.forEach((tag: string) => {
113
+ this.addTag(tag, filename);
92
114
  });
93
115
  }
94
- } catch (error) {
95
- logger.error(`Failed to process file ${result.filename}:`, errorToObject(error));
96
- }
97
- }
116
+ } else if (settledResult.status === 'rejected') {
117
+ // Log unexpected rejections from the async map function itself
118
+ logger.error(`Unexpected error during file processing setup:`, errorToObject(settledResult.reason));
119
+ }
120
+ // Errors during getFileContents/parseProperties are already logged within the map function
121
+ });
98
122
 
99
123
  this.isInitialized = true;
100
124
  this.lastUpdate = Date.now();
@@ -125,13 +149,47 @@ export class TagResource {
125
149
  }
126
150
 
127
151
  /**
128
- * Update the cache if needed
152
+ * Update the cache if needed, preventing race conditions.
129
153
  */
130
154
  private async updateCacheIfNeeded() {
131
155
  const now = Date.now();
132
- if (now - this.lastUpdate > this.updateInterval) {
133
- logger.debug('Tag cache needs update, refreshing...');
134
- await this.initializeCache();
156
+ // Check if cache is fresh enough
157
+ if (now - this.lastUpdate <= this.updateInterval) {
158
+ return; // Cache is up-to-date
159
+ }
160
+
161
+ // Check if an update is already in progress
162
+ if (this.isUpdating) {
163
+ logger.debug('Cache update already in progress, skipping redundant update.');
164
+ // Optionally, wait for the ongoing update instead of returning immediately
165
+ // For now, we return to avoid complexity, potentially serving slightly stale data.
166
+ return;
167
+ }
168
+
169
+ // Acquire the update lock
170
+ this.isUpdating = true;
171
+ logger.debug('Acquired cache update lock.');
172
+
173
+ try {
174
+ // Double-check the update condition after acquiring the lock
175
+ // to handle cases where another process finished updating
176
+ // while this one was waiting for the lock (though less likely with a simple flag).
177
+ const nowAfterLock = Date.now();
178
+ if (nowAfterLock - this.lastUpdate > this.updateInterval) {
179
+ logger.info('Tag cache needs update, refreshing...');
180
+ await this.initializeCache(); // This method updates this.lastUpdate internally
181
+ } else {
182
+ logger.debug('Cache was updated by another process while waiting for lock.');
183
+ }
184
+ } catch (error) {
185
+ // Log error during update, but don't necessarily block other operations
186
+ logger.error('Error during cache update:', errorToObject(error));
187
+ // Decide if the error should be re-thrown or handled gracefully
188
+ // For now, we log and continue, allowing the lock to be released.
189
+ } finally {
190
+ // Release the update lock
191
+ this.isUpdating = false;
192
+ logger.debug('Released cache update lock.');
135
193
  }
136
194
  }
137
195
 
@@ -191,4 +249,4 @@ export class TagResource {
191
249
  throw error;
192
250
  }
193
251
  }
194
- }
252
+ }
@@ -37,24 +37,33 @@ export class PropertyManager {
37
37
  }
38
38
 
39
39
  const frontmatter = match[1];
40
- const properties = parse(frontmatter);
41
-
42
- // Handle tags - don't add # prefix in frontmatter
43
- if (properties.tags && Array.isArray(properties.tags)) {
44
- properties.tags = properties.tags.map((tag: string) =>
45
- tag.startsWith('#') ? tag.substring(1) : tag
46
- );
40
+ // Parse YAML first
41
+ const rawProperties = parse(frontmatter);
42
+
43
+ // Validate the raw parsed object against the schema
44
+ const validationResult = ObsidianPropertiesSchema.safeParse(rawProperties);
45
+
46
+ if (!validationResult.success) {
47
+ // Log validation errors and return empty if invalid
48
+ logger.warn('Frontmatter validation failed:', {
49
+ validationError: validationResult.error.flatten()
50
+ });
51
+ return {}; // Return empty object for invalid frontmatter
47
52
  }
48
53
 
49
- // Validate against schema
50
- const result = ObsidianPropertiesSchema.safeParse(properties);
51
- if (!result.success) {
52
- logger.warn('Property validation warnings:', { validationError: result.error });
53
- // Return the properties with fixed tags
54
- return properties;
54
+ // Use the validated data from now on
55
+ const validatedProperties = validationResult.data;
56
+
57
+ // Handle tags transformation on validated data
58
+ if (validatedProperties.tags && Array.isArray(validatedProperties.tags)) {
59
+ // Create a new array to avoid modifying the validated data directly if needed elsewhere
60
+ validatedProperties.tags = validatedProperties.tags.map((tag: string) =>
61
+ tag.startsWith('#') ? tag.substring(1) : tag
62
+ );
55
63
  }
56
64
 
57
- return result.data;
65
+ // Return the validated (and potentially transformed) properties
66
+ return validatedProperties;
58
67
  } catch (error) {
59
68
  logger.error('Error parsing properties:', error instanceof Error ? error : { error: String(error) });
60
69
  return {};
@@ -230,4 +239,4 @@ export class PropertyManager {
230
239
  };
231
240
  }
232
241
  }
233
- }
242
+ }
@@ -0,0 +1,22 @@
1
+ import { nanoid } from 'nanoid';
2
+
3
+ /**
4
+ * Generates a unique, URL-friendly, 6-character alphanumeric ID.
5
+ * Uses nanoid's default alphabet (A-Za-z0-9_-).
6
+ *
7
+ * @returns A 6-character string ID.
8
+ */
9
+ export function generateShortId(): string {
10
+ return nanoid(6);
11
+ }
12
+
13
+ /**
14
+ * Generates a unique ID with a specified prefix and length.
15
+ *
16
+ * @param prefix - The prefix for the ID (e.g., 'prj', 'tsk', 'knw').
17
+ * @param length - The desired length of the random part of the ID (default: 10).
18
+ * @returns A prefixed ID string (e.g., 'prj_aBcDeFgHiJ').
19
+ */
20
+ export function generatePrefixedId(prefix: string, length: number = 10): string {
21
+ return `${prefix}_${nanoid(length)}`;
22
+ }
@@ -13,22 +13,29 @@ export const TRUNCATION_MESSAGE = "\n\n[Response truncated due to length]";
13
13
  export class TokenCounter {
14
14
  private tokenizer = encoding_for_model("gpt-4"); // This is strictly for token counting, not for LLM inference
15
15
  private isShuttingDown = false;
16
+ private cleanupListener: () => void;
16
17
 
17
18
  constructor() {
18
- // Clean up tokenizer when process exits
19
- const cleanup = () => {
19
+ // Define the cleanup logic
20
+ this.cleanupListener = () => {
20
21
  if (!this.isShuttingDown) {
21
22
  this.isShuttingDown = true;
22
23
  if (this.tokenizer) {
23
24
  this.tokenizer.free();
25
+ // No need to explicitly set this.tokenizer to null,
26
+ // but ensure it's not used after free()
24
27
  }
28
+ // Remove listeners after execution to prevent multiple calls
29
+ // and potential leaks if cleanup is called manually before exit
30
+ this.removeListeners();
25
31
  }
26
32
  };
27
33
 
28
- process.on('exit', cleanup);
29
- process.on('SIGINT', cleanup);
30
- process.on('SIGTERM', cleanup);
31
- process.on('uncaughtException', cleanup);
34
+ // Attach listeners
35
+ process.on('exit', this.cleanupListener);
36
+ process.on('SIGINT', this.cleanupListener);
37
+ process.on('SIGTERM', this.cleanupListener);
38
+ process.on('uncaughtException', this.cleanupListener);
32
39
  }
33
40
 
34
41
  /**
@@ -57,15 +64,22 @@ export class TokenCounter {
57
64
  }
58
65
 
59
66
  /**
60
- * Clean up resources
67
+ * Clean up resources and remove listeners
61
68
  */
62
69
  cleanup(): void {
63
- if (this.tokenizer && !this.isShuttingDown) {
64
- this.isShuttingDown = true;
65
- this.tokenizer.free();
66
- }
70
+ this.cleanupListener(); // Call the main cleanup logic
71
+ }
72
+
73
+ /**
74
+ * Remove process event listeners
75
+ */
76
+ private removeListeners(): void {
77
+ process.off('exit', this.cleanupListener);
78
+ process.off('SIGINT', this.cleanupListener);
79
+ process.off('SIGTERM', this.cleanupListener);
80
+ process.off('uncaughtException', this.cleanupListener);
67
81
  }
68
82
  }
69
83
 
70
84
  // Export a singleton instance
71
- export const tokenCounter = new TokenCounter();
85
+ export const tokenCounter = new TokenCounter();
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Validation utilities for the Obsidian MCP Server
3
3
  */
4
+ import { McpErrorCode } from "../mcp/types.js";
5
+ import { ObsidianError } from "./errors.js";
4
6
 
5
7
  /**
6
8
  * Validates a file path to prevent path traversal attacks and other security issues
@@ -11,12 +13,20 @@ export function validateFilePath(filepath: string): void {
11
13
  // Prevent path traversal attacks
12
14
  const normalizedPath = filepath.replace(/\\/g, '/');
13
15
  if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
14
- throw new Error('Invalid file path: Path traversal not allowed');
16
+ // Use ObsidianError for consistency
17
+ throw new ObsidianError(
18
+ 'Invalid file path: Path traversal not allowed',
19
+ McpErrorCode.BAD_REQUEST
20
+ );
15
21
  }
16
22
 
17
- // Additional path validations
23
+ // Additional path validations (check for absolute paths Unix or Windows style)
18
24
  if (normalizedPath.startsWith('/') || /^[a-zA-Z]:/.test(normalizedPath)) {
19
- throw new Error('Invalid file path: Absolute paths not allowed');
25
+ // Use ObsidianError for consistency
26
+ throw new ObsidianError(
27
+ 'Invalid file path: Absolute paths not allowed',
28
+ McpErrorCode.BAD_REQUEST
29
+ );
20
30
  }
21
31
  }
22
32
 
@@ -92,4 +102,4 @@ export function validateToolArguments(args: unknown, schema: any): { valid: bool
92
102
  }
93
103
 
94
104
  return { valid: errors.length === 0, errors };
95
- }
105
+ }
@@ -1,48 +0,0 @@
1
- # Obsidian MCP Server Tool Examples
2
-
3
- This directory contains example requests and responses for each tool provided by the obsidian-mcp-server. These examples demonstrate the capabilities and expected output format of each tool.
4
-
5
- ## Tools
6
-
7
- ### 1. [List Files in Vault](list-files-in-vault.md)
8
- Lists all files and directories in the root directory of your Obsidian vault. Returns a hierarchical structure of files and folders, including metadata like file type.
9
-
10
- ### 2. [List Files in Directory](list-files-in-dir.md)
11
- Lists all files and directories that exist in a specific Obsidian directory. Returns a hierarchical structure showing files, folders, and their relationships.
12
-
13
- ### 3. [Get File Contents](get-file-contents.md)
14
- Return the content of a single file in your vault. Supports markdown files, text files, and other readable formats.
15
-
16
- ### 4. [Find in File](find-in-file.md)
17
- Full-text search across all files in the vault. Returns matching files with surrounding context for each match.
18
-
19
- ### 5. [Append Content](append-content.md)
20
- Append content to a new or existing file in the vault. Useful for adding new sections, notes, or updates to existing documents.
21
-
22
- ### 6. [Patch Content](patch-content.md)
23
- Update the entire content of an existing note or create a new one. Provides complete control over file contents.
24
-
25
- ### 7. [Complex Search](complex-search.md)
26
- File path pattern matching using JsonLogic queries. Supports operations like glob pattern matching and variable access.
27
-
28
- ### 8. [Get Properties](get-properties.md)
29
- Get properties (title, tags, status, etc.) from an Obsidian note's YAML frontmatter. Returns all available properties including custom fields.
30
-
31
- ### 9. [Update Properties](update-properties.md)
32
- Update properties in an Obsidian note's YAML frontmatter. Intelligently merges arrays and handles custom fields.
33
-
34
- ## Resources
35
-
36
- ### Direct Resources
37
- - obsidian://tags - List of all tags used across the Obsidian vault with their usage counts
38
-
39
- ## Production Readiness
40
-
41
- All tools have been tested and demonstrate:
42
- - Proper input validation and error handling
43
- - Comprehensive and well-structured responses
44
- - Consistent output formatting
45
- - Practical applicability to real-world Obsidian vault management
46
- - Deep integration with Obsidian's features and capabilities
47
-
48
- The examples serve as both documentation and test cases, showing the expected behavior and quality of responses for each tool.
@@ -1,63 +0,0 @@
1
- # Append Content Tool Example
2
-
3
- ## Request 1 (Append to Existing Note)
4
- ```json
5
- {
6
- "filepath": "Projects/Project Alpha/Notes/Meeting Notes.md",
7
- "content": "\n\n## Team Meeting - 2024-01-25\n\n### Discussion Points\n- Reviewed project timeline\n- Discussed technical challenges\n- Assigned new tasks\n\n### Action Items\n- [ ] Update documentation\n- [ ] Schedule follow-up meeting\n- [ ] Share progress report"
8
- }
9
- ```
10
-
11
- ## Response 1
12
- ```json
13
- {
14
- "success": true,
15
- "message": "Content appended successfully"
16
- }
17
- ```
18
-
19
- ## Request 2 (Create New Note)
20
- ```json
21
- {
22
- "filepath": "Daily Notes/2024-01-25.md",
23
- "content": "---\ntitle: Daily Note - January 25, 2024\ntags: [daily-notes]\ndate: 2024-01-25\n---\n\n# Daily Notes\n\n## Tasks\n- [ ] Review project updates\n- [ ] Team meeting at 2 PM\n- [ ] Update documentation\n\n## Notes\n- Started work on new feature\n- Discussed timeline with team\n- Reviewed technical specifications"
24
- }
25
- ```
26
-
27
- ## Response 2
28
- ```json
29
- {
30
- "success": true,
31
- "message": "File created and content appended successfully"
32
- }
33
- ```
34
-
35
- ## Example Use Cases
36
-
37
- 1. **Meeting Notes**
38
- - Add new meeting minutes to existing notes
39
- - Create structured meeting summaries
40
- - Track action items and decisions
41
-
42
- 2. **Daily Notes**
43
- - Create daily journal entries
44
- - Add to existing daily logs
45
- - Maintain consistent note structure
46
-
47
- 3. **Project Documentation**
48
- - Add new sections to documentation
49
- - Update progress logs
50
- - Append new requirements or specifications
51
-
52
- ## Notes
53
- - Can append to existing files or create new ones
54
- - Preserves existing content in the file
55
- - Maintains proper markdown formatting
56
- - Supports YAML frontmatter
57
- - Handles various content types:
58
- * Meeting notes
59
- * Daily logs
60
- * Task lists
61
- * Documentation updates
62
- - File path is relative to vault root
63
- - Creates parent directories if needed
@@ -1,117 +0,0 @@
1
- # Complex Search Tool Example
2
-
3
- ## Request 1 (Simple Glob Pattern)
4
- ```json
5
- {
6
- "query": {
7
- "glob": ["*.md", {"var": "path"}]
8
- }
9
- }
10
- ```
11
-
12
- ## Response 1
13
- ```json
14
- {
15
- "matches": [
16
- "README.md",
17
- "Projects/Project Alpha/Documentation/Requirements.md",
18
- "Daily Notes/2024-01-25.md",
19
- "Templates/Meeting Note.md"
20
- ]
21
- }
22
- ```
23
-
24
- ## Request 2 (Complex Pattern)
25
- ```json
26
- {
27
- "query": {
28
- "and": [
29
- {
30
- "glob": ["Projects/**/*.md", {"var": "path"}]
31
- },
32
- {
33
- "in": [
34
- "Documentation",
35
- {"var": "path"}
36
- ]
37
- }
38
- ]
39
- }
40
- }
41
- ```
42
-
43
- ## Response 2
44
- ```json
45
- {
46
- "matches": [
47
- "Projects/Project Alpha/Documentation/Requirements.md",
48
- "Projects/Project Alpha/Documentation/Architecture.md",
49
- "Projects/Project Beta/Documentation/API.md"
50
- ]
51
- }
52
- ```
53
-
54
- ## Example Use Cases
55
-
56
- 1. **File Organization**
57
- - Find files by pattern matching
58
- - Filter by path components
59
- - Combine multiple search criteria
60
-
61
- 2. **Content Management**
62
- - Locate files in specific directories
63
- - Filter by file extensions
64
- - Find files matching complex patterns
65
-
66
- 3. **Project Navigation**
67
- - Search within project directories
68
- - Find documentation files
69
- - Filter by file location
70
-
71
- ## Notes
72
- - Uses JsonLogic for query construction
73
- - Supports glob pattern matching
74
- - Can combine multiple conditions
75
- - Available operations:
76
- * glob: Pattern matching for paths
77
- * var: Variable access (path)
78
- * and/or: Logical operations
79
- * in: Membership testing
80
- - Path is relative to vault root
81
- - Returns matching file paths
82
- - Useful for complex file filtering
83
-
84
- ## Common Patterns
85
-
86
- 1. **Find All Markdown Files**
87
- ```json
88
- {
89
- "glob": ["**/*.md", {"var": "path"}]
90
- }
91
- ```
92
-
93
- 2. **Files in Specific Directory**
94
- ```json
95
- {
96
- "glob": ["Projects/Project Alpha/**/*", {"var": "path"}]
97
- }
98
- ```
99
-
100
- 3. **Multiple Extensions**
101
- ```json
102
- {
103
- "or": [
104
- {"glob": ["**/*.md", {"var": "path"}]},
105
- {"glob": ["**/*.txt", {"var": "path"}]}
106
- ]
107
- }
108
- ```
109
-
110
- 4. **Exclude Pattern**
111
- ```json
112
- {
113
- "and": [
114
- {"glob": ["**/*.md", {"var": "path"}]},
115
- {"not": {"glob": ["**/Archive/**", {"var": "path"}]}}
116
- ]
117
- }
@@ -1,94 +0,0 @@
1
- # Find in File Tool Example
2
-
3
- ## Request 1 (Many Results)
4
- ```json
5
- {
6
- "query": "tags:",
7
- "contextLength": 20
8
- }
9
- ```
10
-
11
- ## Response 1
12
- ```json
13
- {
14
- "message": "Found 34 files with matches. Showing file names only:",
15
- "results": [
16
- {
17
- "filename": "Projects/Project Alpha/Documentation/Requirements.md",
18
- "matchCount": 1
19
- },
20
- {
21
- "filename": "Projects/Project Beta/Specifications.md",
22
- "matchCount": 2
23
- },
24
- {
25
- "filename": "Daily Notes/2024-01-25.md",
26
- "matchCount": 1
27
- }
28
- ]
29
- }
30
- ```
31
-
32
- ## Request 2 (Few Results)
33
- ```json
34
- {
35
- "query": "priority: high",
36
- "contextLength": 50
37
- }
38
- ```
39
-
40
- ## Response 2
41
- ```json
42
- {
43
- "results": [
44
- {
45
- "filename": "Projects/Project Alpha/tasks.md",
46
- "matches": [
47
- {
48
- "line": 15,
49
- "content": "---\ntitle: Critical Tasks\ntags: [tasks, urgent]\npriority: high\n---\n\n# High Priority Tasks",
50
- "matchStart": 45,
51
- "matchEnd": 57
52
- }
53
- ]
54
- },
55
- {
56
- "filename": "Areas/Work/Deadlines.md",
57
- "matches": [
58
- {
59
- "line": 8,
60
- "content": "## Q1 Deliverables\n- [ ] Feature launch (priority: high)\n- [ ] Security audit",
61
- "matchStart": 35,
62
- "matchEnd": 47
63
- }
64
- ]
65
- }
66
- ]
67
- }
68
- ```
69
-
70
- ## Example Use Cases
71
-
72
- 1. **Content Search**
73
- - Find notes with specific tags or properties
74
- - Search for keywords across the vault
75
- - Locate specific metadata patterns
76
-
77
- 2. **Task Management**
78
- - Find high-priority tasks
79
- - Search for incomplete items
80
- - Locate notes with specific status
81
-
82
- 3. **Knowledge Discovery**
83
- - Find related content across notes
84
- - Search for specific topics or references
85
- - Identify patterns in note metadata
86
-
87
- ## Notes
88
- - Returns either file list or detailed matches based on result count
89
- - Provides line numbers for precise location
90
- - Shows surrounding context for each match
91
- - Context length is customizable
92
- - Supports searching in frontmatter and note content
93
- - Results include match position information
94
- - Can search across all vault files