obsidian-mcp-server 1.2.4 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/obsidian.js CHANGED
@@ -23,12 +23,17 @@ export class ObsidianClient {
23
23
  config;
24
24
  constructor(config) {
25
25
  if (!config.apiKey) {
26
- throw new ObsidianError("API key is required", 40100); // 40100 = Unauthorized
26
+ throw new ObsidianError("Missing API key. To fix this:\n" +
27
+ "1. Install the 'Local REST API' plugin in Obsidian\n" +
28
+ "2. Enable the plugin in Obsidian Settings\n" +
29
+ "3. Copy your API key from Obsidian Settings > Local REST API\n" +
30
+ "4. Provide the API key in your configuration", 40100 // Unauthorized
31
+ );
27
32
  }
28
33
  // Combine defaults with provided config
29
34
  this.config = {
30
35
  ...DEFAULT_OBSIDIAN_CONFIG,
31
- verifySSL: config.verifySSL ?? process.env.NODE_ENV === 'production', // Enable SSL verification in production by default
36
+ verifySSL: config.verifySSL ?? true, // Default to true as required by Obsidian REST API plugin
32
37
  apiKey: config.apiKey,
33
38
  timeout: config.timeout ?? 5000, // 5 second default timeout
34
39
  maxContentLength: config.maxContentLength ?? 50 * 1024 * 1024, // 50MB
@@ -61,7 +66,9 @@ export class ObsidianClient {
61
66
  decompress: true
62
67
  };
63
68
  if (!this.config.verifySSL) {
64
- console.warn("WARNING: SSL verification is disabled. This is not recommended for production use.", process.env.NODE_ENV === 'production' ? "Consider enabling SSL verification." : "This is acceptable for local development only.");
69
+ console.warn("WARNING: SSL verification is disabled. The Obsidian REST API plugin requires HTTPS by default.\n" +
70
+ "Make sure you have configured the certificate as a trusted certificate authority.\n" +
71
+ "See Obsidian Settings > Local REST API > 'How to Access' for setup instructions.");
65
72
  }
66
73
  this.client = axios.create(axiosConfig);
67
74
  }
@@ -88,17 +95,15 @@ export class ObsidianClient {
88
95
  // Prevent path traversal attacks
89
96
  const normalizedPath = filepath.replace(/\\/g, '/');
90
97
  if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
91
- throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001); // 40001 = Path traversal error
98
+ throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001);
92
99
  }
93
100
  // Additional path validations
94
101
  if (normalizedPath.startsWith('/') || /^[a-zA-Z]:/.test(normalizedPath)) {
95
- throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002); // 40002 = Invalid path format
102
+ throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002);
96
103
  }
97
104
  }
98
105
  getErrorCode(status) {
99
- // Convert HTTP status codes to 5-digit error codes
100
106
  switch (status) {
101
- // Client errors (400-499)
102
107
  case 400: return 40000; // Bad request
103
108
  case 401: return 40100; // Unauthorized
104
109
  case 403: return 40300; // Forbidden
@@ -106,19 +111,17 @@ export class ObsidianClient {
106
111
  case 405: return 40500; // Method not allowed
107
112
  case 409: return 40900; // Conflict
108
113
  case 429: return 42900; // Too many requests
109
- // Server errors (500-599)
110
114
  case 500: return 50000; // Internal server error
111
115
  case 501: return 50100; // Not implemented
112
116
  case 502: return 50200; // Bad gateway
113
117
  case 503: return 50300; // Service unavailable
114
118
  case 504: return 50400; // Gateway timeout
115
- // Default cases
116
119
  default:
117
120
  if (status >= 400 && status < 500)
118
121
  return 40000 + (status - 400) * 100;
119
122
  if (status >= 500 && status < 600)
120
123
  return 50000 + (status - 500) * 100;
121
- return 50000; // Default to internal server error
124
+ return 50000;
122
125
  }
123
126
  }
124
127
  async safeRequest(operation) {
@@ -130,16 +133,45 @@ export class ObsidianClient {
130
133
  const axiosError = error;
131
134
  const response = axiosError.response;
132
135
  const errorData = response?.data;
133
- // If the API returns a proper 5-digit error code, use it
134
- // Otherwise, convert HTTP status to 5-digit code
135
- const errorCode = errorData?.errorCode ??
136
- this.getErrorCode(response?.status ?? 500);
137
- const message = errorData?.message ??
138
- axiosError.message ??
139
- "Unknown error";
136
+ // Handle common connection errors with helpful messages
137
+ if (error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
138
+ throw new ObsidianError(`SSL certificate verification failed. You have two options:\n\n` +
139
+ `Option 1 - Enable HTTP (not recommended for production):\n` +
140
+ `1. Go to Obsidian Settings > Local REST API\n` +
141
+ `2. Enable "Enable Non-encrypted (HTTP) Server"\n` +
142
+ `3. Update your client config to use "http" protocol\n\n` +
143
+ `Option 2 - Configure HTTPS (recommended):\n` +
144
+ `1. Go to Obsidian Settings > Local REST API\n` +
145
+ `2. Under 'How to Access', copy the certificate\n` +
146
+ `3. Add the certificate to your system's trusted certificates:\n` +
147
+ ` - On macOS: Add to Keychain Access\n` +
148
+ ` - On Windows: Add to Certificate Manager\n` +
149
+ ` - On Linux: Add to ca-certificates\n` +
150
+ ` For development only: Set verifySSL: false in client config\n\n` +
151
+ `Original error: ${error.message}`, 50001, // SSL error code
152
+ { code: error.code, config: { verifySSL: this.config.verifySSL } });
153
+ }
154
+ if (error.code === 'ECONNREFUSED') {
155
+ throw new ObsidianError(`Connection refused. To fix this:\n` +
156
+ `1. Ensure Obsidian is running\n` +
157
+ `2. Verify the 'Local REST API' plugin is enabled in Obsidian Settings\n` +
158
+ `3. Check that you're using the correct host (${this.config.host}) and port (${this.config.port})\n` +
159
+ `4. Make sure HTTPS is enabled in the plugin settings`, 50002, // Connection refused
160
+ { code: error.code });
161
+ }
162
+ if (response?.status === 401) {
163
+ throw new ObsidianError(`Authentication failed. To fix this:\n` +
164
+ `1. Go to Obsidian Settings > Local REST API\n` +
165
+ `2. Copy your API key from the settings\n` +
166
+ `3. Update your configuration with the new API key\n` +
167
+ `Note: The API key changes when you regenerate certificates`, 40100, // Unauthorized
168
+ { code: error.code });
169
+ }
170
+ // For other errors, use API error code if available
171
+ const errorCode = errorData?.errorCode ?? this.getErrorCode(response?.status ?? 500);
172
+ const message = errorData?.message ?? axiosError.message ?? "Unknown error";
140
173
  throw new ObsidianError(message, errorCode, errorData);
141
174
  }
142
- // For non-Axios errors, use a generic server error code
143
175
  if (error instanceof Error) {
144
176
  throw new ObsidianError(error.message, 50000, error);
145
177
  }
@@ -148,8 +180,6 @@ export class ObsidianClient {
148
180
  }
149
181
  async listFilesInVault() {
150
182
  return this.safeRequest(async () => {
151
- const requestId = crypto.randomUUID();
152
- console.debug(`[${requestId}] Listing vault files`);
153
183
  const response = await this.client.get("/vault/");
154
184
  return response.data.files;
155
185
  });
@@ -157,8 +187,6 @@ export class ObsidianClient {
157
187
  async listFilesInDir(dirpath) {
158
188
  this.validateFilePath(dirpath);
159
189
  return this.safeRequest(async () => {
160
- const requestId = crypto.randomUUID();
161
- console.debug(`[${requestId}] Listing files in directory: ${dirpath}`);
162
190
  const response = await this.client.get(`/vault/${dirpath}/`);
163
191
  return response.data.files;
164
192
  });
@@ -166,30 +194,22 @@ export class ObsidianClient {
166
194
  async getFileContents(filepath) {
167
195
  this.validateFilePath(filepath);
168
196
  return this.safeRequest(async () => {
169
- const requestId = crypto.randomUUID();
170
- console.debug(`[${requestId}] Getting file contents: ${filepath}`);
171
197
  const response = await this.client.get(`/vault/${filepath}`);
172
198
  return response.data;
173
199
  });
174
200
  }
175
201
  async search(query, contextLength = 100) {
176
202
  return this.safeRequest(async () => {
177
- const requestId = crypto.randomUUID();
178
- console.debug(`[${requestId}] Performing simple search: ${query}`);
179
- const response = await this.client.post("/search/simple/", null, {
180
- params: { query, contextLength }
181
- });
203
+ const response = await this.client.post("/search/simple/", null, { params: { query, contextLength } });
182
204
  return response.data;
183
205
  });
184
206
  }
185
207
  async appendContent(filepath, content) {
186
208
  this.validateFilePath(filepath);
187
209
  if (!content || typeof content !== 'string') {
188
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
210
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
189
211
  }
190
212
  return this.safeRequest(async () => {
191
- const requestId = crypto.randomUUID();
192
- console.debug(`[${requestId}] Appending content to: ${filepath}`);
193
213
  await this.client.post(`/vault/${filepath}`, content, {
194
214
  headers: {
195
215
  "Content-Type": "text/markdown"
@@ -200,11 +220,9 @@ export class ObsidianClient {
200
220
  async updateContent(filepath, content) {
201
221
  this.validateFilePath(filepath);
202
222
  if (!content || typeof content !== 'string') {
203
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
223
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
204
224
  }
205
225
  return this.safeRequest(async () => {
206
- const requestId = crypto.randomUUID();
207
- console.debug(`[${requestId}] Updating content in: ${filepath}`);
208
226
  await this.client.put(`/vault/${filepath}`, content, {
209
227
  headers: {
210
228
  "Content-Type": "text/markdown"
@@ -214,9 +232,6 @@ export class ObsidianClient {
214
232
  }
215
233
  async searchJson(query) {
216
234
  return this.safeRequest(async () => {
217
- const requestId = crypto.randomUUID();
218
- console.debug(`[${requestId}] Performing complex search with query:`, JSON.stringify(query));
219
- // Check if this is a tag-based search
220
235
  const isTagSearch = JSON.stringify(query).includes('"contains"') &&
221
236
  JSON.stringify(query).includes('"#"');
222
237
  const response = await this.client.post("/search/", query, {
@@ -225,40 +240,29 @@ export class ObsidianClient {
225
240
  "Accept": "application/vnd.olrapi.note+json"
226
241
  }
227
242
  });
228
- if (isTagSearch) {
229
- return response.data;
230
- }
231
- return response.data;
243
+ return isTagSearch ? response.data : response.data;
232
244
  });
233
245
  }
234
246
  async getStatus() {
235
247
  return this.safeRequest(async () => {
236
- const requestId = crypto.randomUUID();
237
- console.debug(`[${requestId}] Getting server status`);
238
248
  const response = await this.client.get("/");
239
249
  return response.data;
240
250
  });
241
251
  }
242
252
  async listCommands() {
243
253
  return this.safeRequest(async () => {
244
- const requestId = crypto.randomUUID();
245
- console.debug(`[${requestId}] Listing available commands`);
246
254
  const response = await this.client.get("/commands/");
247
255
  return response.data.commands;
248
256
  });
249
257
  }
250
258
  async executeCommand(commandId) {
251
259
  return this.safeRequest(async () => {
252
- const requestId = crypto.randomUUID();
253
- console.debug(`[${requestId}] Executing command: ${commandId}`);
254
260
  await this.client.post(`/commands/${commandId}/`);
255
261
  });
256
262
  }
257
263
  async openFile(filepath, newLeaf = false) {
258
264
  this.validateFilePath(filepath);
259
265
  return this.safeRequest(async () => {
260
- const requestId = crypto.randomUUID();
261
- console.debug(`[${requestId}] Opening file: ${filepath}`);
262
266
  await this.client.post(`/open/${filepath}`, null, {
263
267
  params: { newLeaf }
264
268
  });
@@ -266,8 +270,6 @@ export class ObsidianClient {
266
270
  }
267
271
  async getActiveFile() {
268
272
  return this.safeRequest(async () => {
269
- const requestId = crypto.randomUUID();
270
- console.debug(`[${requestId}] Getting active file`);
271
273
  const response = await this.client.get("/active/", {
272
274
  headers: {
273
275
  "Accept": "application/vnd.olrapi.note+json"
@@ -278,8 +280,6 @@ export class ObsidianClient {
278
280
  }
279
281
  async updateActiveFile(content) {
280
282
  return this.safeRequest(async () => {
281
- const requestId = crypto.randomUUID();
282
- console.debug(`[${requestId}] Updating active file`);
283
283
  await this.client.put("/active/", content, {
284
284
  headers: {
285
285
  "Content-Type": "text/markdown"
@@ -289,15 +289,11 @@ export class ObsidianClient {
289
289
  }
290
290
  async deleteActiveFile() {
291
291
  return this.safeRequest(async () => {
292
- const requestId = crypto.randomUUID();
293
- console.debug(`[${requestId}] Deleting active file`);
294
292
  await this.client.delete("/active/");
295
293
  });
296
294
  }
297
295
  async patchActiveFile(operation, targetType, target, content, options) {
298
296
  return this.safeRequest(async () => {
299
- const requestId = crypto.randomUUID();
300
- console.debug(`[${requestId}] Patching active file: ${operation} ${targetType} ${target}`);
301
297
  const headers = {
302
298
  "Operation": operation,
303
299
  "Target-Type": targetType,
@@ -315,8 +311,6 @@ export class ObsidianClient {
315
311
  }
316
312
  async getPeriodicNote(period) {
317
313
  return this.safeRequest(async () => {
318
- const requestId = crypto.randomUUID();
319
- console.debug(`[${requestId}] Getting ${period} note`);
320
314
  const response = await this.client.get(`/periodic/${period}/`, {
321
315
  headers: {
322
316
  "Accept": "application/vnd.olrapi.note+json"
@@ -327,8 +321,6 @@ export class ObsidianClient {
327
321
  }
328
322
  async updatePeriodicNote(period, content) {
329
323
  return this.safeRequest(async () => {
330
- const requestId = crypto.randomUUID();
331
- console.debug(`[${requestId}] Updating ${period} note`);
332
324
  await this.client.put(`/periodic/${period}/`, content, {
333
325
  headers: {
334
326
  "Content-Type": "text/markdown"
@@ -338,15 +330,11 @@ export class ObsidianClient {
338
330
  }
339
331
  async deletePeriodicNote(period) {
340
332
  return this.safeRequest(async () => {
341
- const requestId = crypto.randomUUID();
342
- console.debug(`[${requestId}] Deleting ${period} note`);
343
333
  await this.client.delete(`/periodic/${period}/`);
344
334
  });
345
335
  }
346
336
  async patchPeriodicNote(period, operation, targetType, target, content, options) {
347
337
  return this.safeRequest(async () => {
348
- const requestId = crypto.randomUUID();
349
- console.debug(`[${requestId}] Patching ${period} note: ${operation} ${targetType} ${target}`);
350
338
  const headers = {
351
339
  "Operation": operation,
352
340
  "Target-Type": targetType,
@@ -1,8 +1,16 @@
1
+ import { PropertyManager } from "./properties.js";
1
2
  export class TagResource {
2
3
  client;
3
4
  static TAG_PATTERN = /#[a-zA-Z0-9_-]+/g;
5
+ tagCache = new Map();
6
+ propertyManager;
7
+ isInitialized = false;
8
+ lastUpdate = 0;
9
+ updateInterval = 5000; // 5 seconds
4
10
  constructor(client) {
5
11
  this.client = client;
12
+ this.propertyManager = new PropertyManager(client);
13
+ this.initializeCache();
6
14
  }
7
15
  getResourceDescription() {
8
16
  return {
@@ -12,37 +20,67 @@ export class TagResource {
12
20
  mimeType: "application/json"
13
21
  };
14
22
  }
15
- async getContent() {
23
+ async initializeCache() {
16
24
  try {
17
- // Search for files containing #
25
+ // Get all markdown files
18
26
  const query = {
19
- "contains": [{ "var": "content" }, "#"]
27
+ "glob": ["**/*.md", { "var": "path" }]
20
28
  };
21
29
  const results = await this.client.searchJson(query);
22
- // Process results to extract tags
23
- const tagMap = new Map();
24
- let totalOccurrences = 0;
25
- let scannedFiles = 0;
26
- results.forEach((result) => {
27
- scannedFiles++;
28
- if ('matches' in result) {
29
- result.matches.forEach((match) => {
30
- const tags = match.context.match(TagResource.TAG_PATTERN);
31
- if (tags) {
32
- tags.forEach((tag) => {
33
- if (!tagMap.has(tag)) {
34
- tagMap.set(tag, new Set());
35
- }
36
- tagMap.get(tag).add(result.filename);
37
- totalOccurrences++;
38
- });
39
- }
30
+ this.tagCache.clear();
31
+ // Process each file
32
+ for (const result of results) {
33
+ if (!('filename' in result))
34
+ continue;
35
+ try {
36
+ const content = await this.client.getFileContents(result.filename);
37
+ // Extract tags from frontmatter
38
+ const properties = this.propertyManager.parseProperties(content);
39
+ if (properties.tags) {
40
+ properties.tags.forEach((tag) => {
41
+ this.addTag(tag, result.filename);
42
+ });
43
+ }
44
+ // Extract inline tags
45
+ const inlineTags = content.match(TagResource.TAG_PATTERN) || [];
46
+ inlineTags.forEach(tag => {
47
+ this.addTag(tag, result.filename);
40
48
  });
41
49
  }
42
- });
43
- // Convert to sorted response format
50
+ catch (error) {
51
+ console.error(`Failed to process file ${result.filename}:`, error);
52
+ }
53
+ }
54
+ this.isInitialized = true;
55
+ this.lastUpdate = Date.now();
56
+ }
57
+ catch (error) {
58
+ console.error("Failed to initialize tag cache:", error);
59
+ throw error;
60
+ }
61
+ }
62
+ addTag(tag, filepath) {
63
+ if (!this.tagCache.has(tag)) {
64
+ this.tagCache.set(tag, new Set());
65
+ }
66
+ this.tagCache.get(tag).add(filepath);
67
+ }
68
+ async updateCacheIfNeeded() {
69
+ const now = Date.now();
70
+ if (now - this.lastUpdate > this.updateInterval) {
71
+ await this.initializeCache();
72
+ }
73
+ }
74
+ async getContent() {
75
+ try {
76
+ if (!this.isInitialized) {
77
+ await this.initializeCache();
78
+ }
79
+ else {
80
+ await this.updateCacheIfNeeded();
81
+ }
44
82
  const response = {
45
- tags: Array.from(tagMap.entries())
83
+ tags: Array.from(this.tagCache.entries())
46
84
  .map(([name, files]) => ({
47
85
  name,
48
86
  count: files.size,
@@ -50,18 +88,22 @@ export class TagResource {
50
88
  }))
51
89
  .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
52
90
  metadata: {
53
- totalOccurrences,
54
- uniqueTags: tagMap.size,
55
- scannedFiles
91
+ totalOccurrences: Array.from(this.tagCache.values())
92
+ .reduce((sum, files) => sum + files.size, 0),
93
+ uniqueTags: this.tagCache.size,
94
+ scannedFiles: new Set(Array.from(this.tagCache.values())
95
+ .flatMap(files => Array.from(files))).size,
96
+ lastUpdate: this.lastUpdate
56
97
  }
57
98
  };
58
99
  return [{
59
100
  type: "text",
60
- text: JSON.stringify(response, null, 2)
101
+ text: JSON.stringify(response, null, 2),
102
+ uri: this.getResourceDescription().uri
61
103
  }];
62
104
  }
63
105
  catch (error) {
64
- console.error("Failed to fetch tags:", error);
106
+ console.error("Failed to get tags:", error);
65
107
  throw error;
66
108
  }
67
109
  }
package/build/tools.js CHANGED
@@ -641,7 +641,8 @@ export class GetTagsToolHandler extends BaseToolHandler {
641
641
  metadata: {
642
642
  totalOccurrences,
643
643
  uniqueTags: tagMap.size,
644
- scannedFiles
644
+ scannedFiles,
645
+ lastUpdate: Date.now()
645
646
  }
646
647
  };
647
648
  return this.createResponse(response);
package/build/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const DEFAULT_OBSIDIAN_CONFIG = {
2
- protocol: "https",
2
+ protocol: "https", // HTTPS required by default in Obsidian REST API plugin
3
3
  host: "127.0.0.1",
4
4
  port: 27124
5
5
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obsidian-mcp-server",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "description": "Model Context Protocol server for Obsidian integration with token-aware response handling",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
package/src/obsidian.ts CHANGED
@@ -41,13 +41,20 @@ export class ObsidianClient {
41
41
 
42
42
  constructor(config: ObsidianConfig) {
43
43
  if (!config.apiKey) {
44
- throw new ObsidianError("API key is required", 40100); // 40100 = Unauthorized
44
+ throw new ObsidianError(
45
+ "Missing API key. To fix this:\n" +
46
+ "1. Install the 'Local REST API' plugin in Obsidian\n" +
47
+ "2. Enable the plugin in Obsidian Settings\n" +
48
+ "3. Copy your API key from Obsidian Settings > Local REST API\n" +
49
+ "4. Provide the API key in your configuration",
50
+ 40100 // Unauthorized
51
+ );
45
52
  }
46
53
 
47
54
  // Combine defaults with provided config
48
55
  this.config = {
49
56
  ...DEFAULT_OBSIDIAN_CONFIG,
50
- verifySSL: config.verifySSL ?? process.env.NODE_ENV === 'production', // Enable SSL verification in production by default
57
+ verifySSL: config.verifySSL ?? true, // Default to true as required by Obsidian REST API plugin
51
58
  apiKey: config.apiKey,
52
59
  timeout: config.timeout ?? 5000, // 5 second default timeout
53
60
  maxContentLength: config.maxContentLength ?? 50 * 1024 * 1024, // 50MB
@@ -84,8 +91,9 @@ export class ObsidianClient {
84
91
 
85
92
  if (!this.config.verifySSL) {
86
93
  console.warn(
87
- "WARNING: SSL verification is disabled. This is not recommended for production use.",
88
- process.env.NODE_ENV === 'production' ? "Consider enabling SSL verification." : "This is acceptable for local development only."
94
+ "WARNING: SSL verification is disabled. The Obsidian REST API plugin requires HTTPS by default.\n" +
95
+ "Make sure you have configured the certificate as a trusted certificate authority.\n" +
96
+ "See Obsidian Settings > Local REST API > 'How to Access' for setup instructions."
89
97
  );
90
98
  }
91
99
 
@@ -121,19 +129,17 @@ export class ObsidianClient {
121
129
  // Prevent path traversal attacks
122
130
  const normalizedPath = filepath.replace(/\\/g, '/');
123
131
  if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
124
- throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001); // 40001 = Path traversal error
132
+ throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001);
125
133
  }
126
134
 
127
135
  // Additional path validations
128
136
  if (normalizedPath.startsWith('/') || /^[a-zA-Z]:/.test(normalizedPath)) {
129
- throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002); // 40002 = Invalid path format
137
+ throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002);
130
138
  }
131
139
  }
132
140
 
133
141
  private getErrorCode(status: number): number {
134
- // Convert HTTP status codes to 5-digit error codes
135
142
  switch (status) {
136
- // Client errors (400-499)
137
143
  case 400: return 40000; // Bad request
138
144
  case 401: return 40100; // Unauthorized
139
145
  case 403: return 40300; // Forbidden
@@ -141,19 +147,15 @@ export class ObsidianClient {
141
147
  case 405: return 40500; // Method not allowed
142
148
  case 409: return 40900; // Conflict
143
149
  case 429: return 42900; // Too many requests
144
-
145
- // Server errors (500-599)
146
150
  case 500: return 50000; // Internal server error
147
151
  case 501: return 50100; // Not implemented
148
152
  case 502: return 50200; // Bad gateway
149
153
  case 503: return 50300; // Service unavailable
150
154
  case 504: return 50400; // Gateway timeout
151
-
152
- // Default cases
153
155
  default:
154
156
  if (status >= 400 && status < 500) return 40000 + (status - 400) * 100;
155
157
  if (status >= 500 && status < 600) return 50000 + (status - 500) * 100;
156
- return 50000; // Default to internal server error
158
+ return 50000;
157
159
  }
158
160
  }
159
161
 
@@ -165,20 +167,59 @@ export class ObsidianClient {
165
167
  const axiosError = error as AxiosError<ApiError>;
166
168
  const response = axiosError.response;
167
169
  const errorData = response?.data;
168
-
169
- // If the API returns a proper 5-digit error code, use it
170
- // Otherwise, convert HTTP status to 5-digit code
171
- const errorCode = errorData?.errorCode ??
172
- this.getErrorCode(response?.status ?? 500);
173
-
174
- const message = errorData?.message ??
175
- axiosError.message ??
176
- "Unknown error";
177
-
170
+
171
+ // Handle common connection errors with helpful messages
172
+ if (error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
173
+ throw new ObsidianError(
174
+ `SSL certificate verification failed. You have two options:\n\n` +
175
+ `Option 1 - Enable HTTP (not recommended for production):\n` +
176
+ `1. Go to Obsidian Settings > Local REST API\n` +
177
+ `2. Enable "Enable Non-encrypted (HTTP) Server"\n` +
178
+ `3. Update your client config to use "http" protocol\n\n` +
179
+ `Option 2 - Configure HTTPS (recommended):\n` +
180
+ `1. Go to Obsidian Settings > Local REST API\n` +
181
+ `2. Under 'How to Access', copy the certificate\n` +
182
+ `3. Add the certificate to your system's trusted certificates:\n` +
183
+ ` - On macOS: Add to Keychain Access\n` +
184
+ ` - On Windows: Add to Certificate Manager\n` +
185
+ ` - On Linux: Add to ca-certificates\n` +
186
+ ` For development only: Set verifySSL: false in client config\n\n` +
187
+ `Original error: ${error.message}`,
188
+ 50001, // SSL error code
189
+ { code: error.code, config: { verifySSL: this.config.verifySSL } }
190
+ );
191
+ }
192
+
193
+ if (error.code === 'ECONNREFUSED') {
194
+ throw new ObsidianError(
195
+ `Connection refused. To fix this:\n` +
196
+ `1. Ensure Obsidian is running\n` +
197
+ `2. Verify the 'Local REST API' plugin is enabled in Obsidian Settings\n` +
198
+ `3. Check that you're using the correct host (${this.config.host}) and port (${this.config.port})\n` +
199
+ `4. Make sure HTTPS is enabled in the plugin settings`,
200
+ 50002, // Connection refused
201
+ { code: error.code }
202
+ );
203
+ }
204
+
205
+ if (response?.status === 401) {
206
+ throw new ObsidianError(
207
+ `Authentication failed. To fix this:\n` +
208
+ `1. Go to Obsidian Settings > Local REST API\n` +
209
+ `2. Copy your API key from the settings\n` +
210
+ `3. Update your configuration with the new API key\n` +
211
+ `Note: The API key changes when you regenerate certificates`,
212
+ 40100, // Unauthorized
213
+ { code: error.code }
214
+ );
215
+ }
216
+
217
+ // For other errors, use API error code if available
218
+ const errorCode = errorData?.errorCode ?? this.getErrorCode(response?.status ?? 500);
219
+ const message = errorData?.message ?? axiosError.message ?? "Unknown error";
178
220
  throw new ObsidianError(message, errorCode, errorData);
179
221
  }
180
222
 
181
- // For non-Axios errors, use a generic server error code
182
223
  if (error instanceof Error) {
183
224
  throw new ObsidianError(error.message, 50000, error);
184
225
  }
@@ -189,8 +230,6 @@ export class ObsidianClient {
189
230
 
190
231
  async listFilesInVault(): Promise<ObsidianFile[]> {
191
232
  return this.safeRequest(async () => {
192
- const requestId = crypto.randomUUID();
193
- console.debug(`[${requestId}] Listing vault files`);
194
233
  const response = await this.client.get<{ files: ObsidianFile[] }>("/vault/");
195
234
  return response.data.files;
196
235
  });
@@ -199,8 +238,6 @@ export class ObsidianClient {
199
238
  async listFilesInDir(dirpath: string): Promise<ObsidianFile[]> {
200
239
  this.validateFilePath(dirpath);
201
240
  return this.safeRequest(async () => {
202
- const requestId = crypto.randomUUID();
203
- console.debug(`[${requestId}] Listing files in directory: ${dirpath}`);
204
241
  const response = await this.client.get<{ files: ObsidianFile[] }>(`/vault/${dirpath}/`);
205
242
  return response.data.files;
206
243
  });
@@ -209,8 +246,6 @@ export class ObsidianClient {
209
246
  async getFileContents(filepath: string): Promise<string> {
210
247
  this.validateFilePath(filepath);
211
248
  return this.safeRequest(async () => {
212
- const requestId = crypto.randomUUID();
213
- console.debug(`[${requestId}] Getting file contents: ${filepath}`);
214
249
  const response = await this.client.get<string>(`/vault/${filepath}`);
215
250
  return response.data;
216
251
  });
@@ -218,14 +253,10 @@ export class ObsidianClient {
218
253
 
219
254
  async search(query: string, contextLength: number = 100): Promise<SimpleSearchResult[]> {
220
255
  return this.safeRequest(async () => {
221
- const requestId = crypto.randomUUID();
222
- console.debug(`[${requestId}] Performing simple search: ${query}`);
223
256
  const response = await this.client.post<SimpleSearchResult[]>(
224
257
  "/search/simple/",
225
258
  null,
226
- {
227
- params: { query, contextLength }
228
- }
259
+ { params: { query, contextLength } }
229
260
  );
230
261
  return response.data;
231
262
  });
@@ -234,11 +265,9 @@ export class ObsidianClient {
234
265
  async appendContent(filepath: string, content: string): Promise<void> {
235
266
  this.validateFilePath(filepath);
236
267
  if (!content || typeof content !== 'string') {
237
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
268
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
238
269
  }
239
270
  return this.safeRequest(async () => {
240
- const requestId = crypto.randomUUID();
241
- console.debug(`[${requestId}] Appending content to: ${filepath}`);
242
271
  await this.client.post(
243
272
  `/vault/${filepath}`,
244
273
  content,
@@ -254,12 +283,10 @@ export class ObsidianClient {
254
283
  async updateContent(filepath: string, content: string): Promise<void> {
255
284
  this.validateFilePath(filepath);
256
285
  if (!content || typeof content !== 'string') {
257
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
286
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
258
287
  }
259
288
 
260
289
  return this.safeRequest(async () => {
261
- const requestId = crypto.randomUUID();
262
- console.debug(`[${requestId}] Updating content in: ${filepath}`);
263
290
  await this.client.put(
264
291
  `/vault/${filepath}`,
265
292
  content,
@@ -274,10 +301,6 @@ export class ObsidianClient {
274
301
 
275
302
  async searchJson(query: JsonLogicQuery): Promise<SearchResponse[]> {
276
303
  return this.safeRequest(async () => {
277
- const requestId = crypto.randomUUID();
278
- console.debug(`[${requestId}] Performing complex search with query:`, JSON.stringify(query));
279
-
280
- // Check if this is a tag-based search
281
304
  const isTagSearch = JSON.stringify(query).includes('"contains"') &&
282
305
  JSON.stringify(query).includes('"#"');
283
306
 
@@ -292,17 +315,12 @@ export class ObsidianClient {
292
315
  }
293
316
  );
294
317
 
295
- if (isTagSearch) {
296
- return response.data as SimpleSearchResult[];
297
- }
298
- return response.data as SearchResult[];
318
+ return isTagSearch ? response.data as SimpleSearchResult[] : response.data as SearchResult[];
299
319
  });
300
320
  }
301
321
 
302
322
  async getStatus(): Promise<ObsidianStatus> {
303
323
  return this.safeRequest(async () => {
304
- const requestId = crypto.randomUUID();
305
- console.debug(`[${requestId}] Getting server status`);
306
324
  const response = await this.client.get<ObsidianStatus>("/");
307
325
  return response.data;
308
326
  });
@@ -310,8 +328,6 @@ export class ObsidianClient {
310
328
 
311
329
  async listCommands(): Promise<ObsidianCommand[]> {
312
330
  return this.safeRequest(async () => {
313
- const requestId = crypto.randomUUID();
314
- console.debug(`[${requestId}] Listing available commands`);
315
331
  const response = await this.client.get<{commands: ObsidianCommand[]}>("/commands/");
316
332
  return response.data.commands;
317
333
  });
@@ -319,8 +335,6 @@ export class ObsidianClient {
319
335
 
320
336
  async executeCommand(commandId: string): Promise<void> {
321
337
  return this.safeRequest(async () => {
322
- const requestId = crypto.randomUUID();
323
- console.debug(`[${requestId}] Executing command: ${commandId}`);
324
338
  await this.client.post(`/commands/${commandId}/`);
325
339
  });
326
340
  }
@@ -328,8 +342,6 @@ export class ObsidianClient {
328
342
  async openFile(filepath: string, newLeaf: boolean = false): Promise<void> {
329
343
  this.validateFilePath(filepath);
330
344
  return this.safeRequest(async () => {
331
- const requestId = crypto.randomUUID();
332
- console.debug(`[${requestId}] Opening file: ${filepath}`);
333
345
  await this.client.post(`/open/${filepath}`, null, {
334
346
  params: { newLeaf }
335
347
  });
@@ -338,8 +350,6 @@ export class ObsidianClient {
338
350
 
339
351
  async getActiveFile(): Promise<NoteJson> {
340
352
  return this.safeRequest(async () => {
341
- const requestId = crypto.randomUUID();
342
- console.debug(`[${requestId}] Getting active file`);
343
353
  const response = await this.client.get<NoteJson>("/active/", {
344
354
  headers: {
345
355
  "Accept": "application/vnd.olrapi.note+json"
@@ -351,8 +361,6 @@ export class ObsidianClient {
351
361
 
352
362
  async updateActiveFile(content: string): Promise<void> {
353
363
  return this.safeRequest(async () => {
354
- const requestId = crypto.randomUUID();
355
- console.debug(`[${requestId}] Updating active file`);
356
364
  await this.client.put("/active/", content, {
357
365
  headers: {
358
366
  "Content-Type": "text/markdown"
@@ -363,21 +371,22 @@ export class ObsidianClient {
363
371
 
364
372
  async deleteActiveFile(): Promise<void> {
365
373
  return this.safeRequest(async () => {
366
- const requestId = crypto.randomUUID();
367
- console.debug(`[${requestId}] Deleting active file`);
368
374
  await this.client.delete("/active/");
369
375
  });
370
376
  }
371
377
 
372
- async patchActiveFile(operation: "append" | "prepend" | "replace", targetType: "heading" | "block" | "frontmatter", target: string, content: string, options?: {
373
- delimiter?: string;
374
- trimWhitespace?: boolean;
375
- contentType?: "text/markdown" | "application/json";
376
- }): Promise<void> {
378
+ async patchActiveFile(
379
+ operation: "append" | "prepend" | "replace",
380
+ targetType: "heading" | "block" | "frontmatter",
381
+ target: string,
382
+ content: string,
383
+ options?: {
384
+ delimiter?: string;
385
+ trimWhitespace?: boolean;
386
+ contentType?: "text/markdown" | "application/json";
387
+ }
388
+ ): Promise<void> {
377
389
  return this.safeRequest(async () => {
378
- const requestId = crypto.randomUUID();
379
- console.debug(`[${requestId}] Patching active file: ${operation} ${targetType} ${target}`);
380
-
381
390
  const headers: Record<string, string> = {
382
391
  "Operation": operation,
383
392
  "Target-Type": targetType,
@@ -398,8 +407,6 @@ export class ObsidianClient {
398
407
 
399
408
  async getPeriodicNote(period: PeriodType["type"]): Promise<NoteJson> {
400
409
  return this.safeRequest(async () => {
401
- const requestId = crypto.randomUUID();
402
- console.debug(`[${requestId}] Getting ${period} note`);
403
410
  const response = await this.client.get<NoteJson>(`/periodic/${period}/`, {
404
411
  headers: {
405
412
  "Accept": "application/vnd.olrapi.note+json"
@@ -411,8 +418,6 @@ export class ObsidianClient {
411
418
 
412
419
  async updatePeriodicNote(period: PeriodType["type"], content: string): Promise<void> {
413
420
  return this.safeRequest(async () => {
414
- const requestId = crypto.randomUUID();
415
- console.debug(`[${requestId}] Updating ${period} note`);
416
421
  await this.client.put(`/periodic/${period}/`, content, {
417
422
  headers: {
418
423
  "Content-Type": "text/markdown"
@@ -423,21 +428,23 @@ export class ObsidianClient {
423
428
 
424
429
  async deletePeriodicNote(period: PeriodType["type"]): Promise<void> {
425
430
  return this.safeRequest(async () => {
426
- const requestId = crypto.randomUUID();
427
- console.debug(`[${requestId}] Deleting ${period} note`);
428
431
  await this.client.delete(`/periodic/${period}/`);
429
432
  });
430
433
  }
431
434
 
432
- async patchPeriodicNote(period: PeriodType["type"], operation: "append" | "prepend" | "replace", targetType: "heading" | "block" | "frontmatter", target: string, content: string, options?: {
433
- delimiter?: string;
434
- trimWhitespace?: boolean;
435
- contentType?: "text/markdown" | "application/json";
436
- }): Promise<void> {
435
+ async patchPeriodicNote(
436
+ period: PeriodType["type"],
437
+ operation: "append" | "prepend" | "replace",
438
+ targetType: "heading" | "block" | "frontmatter",
439
+ target: string,
440
+ content: string,
441
+ options?: {
442
+ delimiter?: string;
443
+ trimWhitespace?: boolean;
444
+ contentType?: "text/markdown" | "application/json";
445
+ }
446
+ ): Promise<void> {
437
447
  return this.safeRequest(async () => {
438
- const requestId = crypto.randomUUID();
439
- console.debug(`[${requestId}] Patching ${period} note: ${operation} ${targetType} ${target}`);
440
-
441
448
  const headers: Record<string, string> = {
442
449
  "Operation": operation,
443
450
  "Target-Type": targetType,
package/src/resources.ts CHANGED
@@ -1,11 +1,20 @@
1
1
  import { Resource, TextContent } from "@modelcontextprotocol/sdk/types.js";
2
2
  import { ObsidianClient } from "./obsidian.js";
3
- import { TagResponse, SearchMatch, SimpleSearchResult, SearchResponse } from "./types.js";
3
+ import { TagResponse, ObsidianFile, JsonLogicQuery } from "./types.js";
4
+ import { PropertyManager } from "./properties.js";
4
5
 
5
6
  export class TagResource {
6
7
  private static readonly TAG_PATTERN = /#[a-zA-Z0-9_-]+/g;
8
+ private tagCache: Map<string, Set<string>> = new Map();
9
+ private propertyManager: PropertyManager;
10
+ private isInitialized = false;
11
+ private lastUpdate = 0;
12
+ private updateInterval = 5000; // 5 seconds
7
13
 
8
- constructor(private client: ObsidianClient) {}
14
+ constructor(private client: ObsidianClient) {
15
+ this.propertyManager = new PropertyManager(client);
16
+ this.initializeCache();
17
+ }
9
18
 
10
19
  getResourceDescription(): Resource {
11
20
  return {
@@ -16,41 +25,73 @@ export class TagResource {
16
25
  };
17
26
  }
18
27
 
19
- async getContent(): Promise<TextContent[]> {
28
+ private async initializeCache() {
20
29
  try {
21
- // Search for files containing #
22
- const query = {
23
- "contains": [{ "var": "content" }, "#"]
30
+ // Get all markdown files
31
+ const query: JsonLogicQuery = {
32
+ "glob": ["**/*.md", { "var": "path" }]
24
33
  };
25
-
34
+
26
35
  const results = await this.client.searchJson(query);
36
+ this.tagCache.clear();
27
37
 
28
- // Process results to extract tags
29
- const tagMap = new Map<string, Set<string>>();
30
- let totalOccurrences = 0;
31
- let scannedFiles = 0;
32
-
33
- results.forEach((result: SearchResponse) => {
34
- scannedFiles++;
35
- if ('matches' in result) {
36
- result.matches.forEach((match: SearchMatch) => {
37
- const tags = match.context.match(TagResource.TAG_PATTERN);
38
- if (tags) {
39
- tags.forEach((tag: string) => {
40
- if (!tagMap.has(tag)) {
41
- tagMap.set(tag, new Set());
42
- }
43
- tagMap.get(tag)!.add(result.filename);
44
- totalOccurrences++;
45
- });
46
- }
38
+ // Process each file
39
+ for (const result of results) {
40
+ if (!('filename' in result)) continue;
41
+
42
+ try {
43
+ const content = await this.client.getFileContents(result.filename);
44
+
45
+ // Extract tags from frontmatter
46
+ const properties = this.propertyManager.parseProperties(content);
47
+ if (properties.tags) {
48
+ properties.tags.forEach((tag: string) => {
49
+ this.addTag(tag, result.filename);
50
+ });
51
+ }
52
+
53
+ // Extract inline tags
54
+ const inlineTags = content.match(TagResource.TAG_PATTERN) || [];
55
+ inlineTags.forEach(tag => {
56
+ this.addTag(tag, result.filename);
47
57
  });
58
+ } catch (error) {
59
+ console.error(`Failed to process file ${result.filename}:`, error);
48
60
  }
49
- });
61
+ }
62
+
63
+ this.isInitialized = true;
64
+ this.lastUpdate = Date.now();
65
+ } catch (error) {
66
+ console.error("Failed to initialize tag cache:", error);
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ private addTag(tag: string, filepath: string) {
72
+ if (!this.tagCache.has(tag)) {
73
+ this.tagCache.set(tag, new Set());
74
+ }
75
+ this.tagCache.get(tag)!.add(filepath);
76
+ }
77
+
78
+ private async updateCacheIfNeeded() {
79
+ const now = Date.now();
80
+ if (now - this.lastUpdate > this.updateInterval) {
81
+ await this.initializeCache();
82
+ }
83
+ }
84
+
85
+ async getContent(): Promise<TextContent[]> {
86
+ try {
87
+ if (!this.isInitialized) {
88
+ await this.initializeCache();
89
+ } else {
90
+ await this.updateCacheIfNeeded();
91
+ }
50
92
 
51
- // Convert to sorted response format
52
93
  const response: TagResponse = {
53
- tags: Array.from(tagMap.entries())
94
+ tags: Array.from(this.tagCache.entries())
54
95
  .map(([name, files]) => ({
55
96
  name,
56
97
  count: files.size,
@@ -58,18 +99,24 @@ export class TagResource {
58
99
  }))
59
100
  .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
60
101
  metadata: {
61
- totalOccurrences,
62
- uniqueTags: tagMap.size,
63
- scannedFiles
102
+ totalOccurrences: Array.from(this.tagCache.values())
103
+ .reduce((sum, files) => sum + files.size, 0),
104
+ uniqueTags: this.tagCache.size,
105
+ scannedFiles: new Set(
106
+ Array.from(this.tagCache.values())
107
+ .flatMap(files => Array.from(files))
108
+ ).size,
109
+ lastUpdate: this.lastUpdate
64
110
  }
65
111
  };
66
112
 
67
113
  return [{
68
114
  type: "text",
69
- text: JSON.stringify(response, null, 2)
115
+ text: JSON.stringify(response, null, 2),
116
+ uri: this.getResourceDescription().uri
70
117
  }];
71
118
  } catch (error) {
72
- console.error("Failed to fetch tags:", error);
119
+ console.error("Failed to get tags:", error);
73
120
  throw error;
74
121
  }
75
122
  }
package/src/tools.ts CHANGED
@@ -714,7 +714,8 @@ export class GetTagsToolHandler extends BaseToolHandler<GetTagsArgs> {
714
714
  metadata: {
715
715
  totalOccurrences,
716
716
  uniqueTags: tagMap.size,
717
- scannedFiles
717
+ scannedFiles,
718
+ lastUpdate: Date.now()
718
719
  }
719
720
  };
720
721
 
package/src/types.ts CHANGED
@@ -15,7 +15,7 @@ export interface ObsidianServerConfig {
15
15
  }
16
16
 
17
17
  export const DEFAULT_OBSIDIAN_CONFIG: ObsidianServerConfig = {
18
- protocol: "https",
18
+ protocol: "https", // HTTPS required by default in Obsidian REST API plugin
19
19
  host: "127.0.0.1",
20
20
  port: 27124
21
21
  } as const;
@@ -129,6 +129,7 @@ export interface TagMetadata {
129
129
  totalOccurrences: number;
130
130
  uniqueTags: number;
131
131
  scannedFiles: number;
132
+ lastUpdate: number; // Timestamp of last cache update
132
133
  }
133
134
 
134
135
  export interface TagResponse {