obsidian-mcp-server 1.2.4 → 1.2.5

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,37 @@ 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. To fix this:\n` +
139
+ `1. Go to Obsidian Settings > Local REST API\n` +
140
+ `2. Under 'How to Access', copy the certificate\n` +
141
+ `3. Configure the certificate as a trusted certificate authority\n` +
142
+ `4. Ensure you're using HTTPS (HTTP is disabled by default)\n` +
143
+ `Original error: ${error.message}`, 50001, // SSL error code
144
+ { code: error.code, config: { verifySSL: this.config.verifySSL } });
145
+ }
146
+ if (error.code === 'ECONNREFUSED') {
147
+ throw new ObsidianError(`Connection refused. To fix this:\n` +
148
+ `1. Ensure Obsidian is running\n` +
149
+ `2. Verify the 'Local REST API' plugin is enabled in Obsidian Settings\n` +
150
+ `3. Check that you're using the correct host (${this.config.host}) and port (${this.config.port})\n` +
151
+ `4. Make sure HTTPS is enabled in the plugin settings`, 50002, // Connection refused
152
+ { code: error.code });
153
+ }
154
+ if (response?.status === 401) {
155
+ throw new ObsidianError(`Authentication failed. To fix this:\n` +
156
+ `1. Go to Obsidian Settings > Local REST API\n` +
157
+ `2. Copy your API key from the settings\n` +
158
+ `3. Update your configuration with the new API key\n` +
159
+ `Note: The API key changes when you regenerate certificates`, 40100, // Unauthorized
160
+ { code: error.code });
161
+ }
162
+ // For other errors, use API error code if available
163
+ const errorCode = errorData?.errorCode ?? this.getErrorCode(response?.status ?? 500);
164
+ const message = errorData?.message ?? axiosError.message ?? "Unknown error";
140
165
  throw new ObsidianError(message, errorCode, errorData);
141
166
  }
142
- // For non-Axios errors, use a generic server error code
143
167
  if (error instanceof Error) {
144
168
  throw new ObsidianError(error.message, 50000, error);
145
169
  }
@@ -148,8 +172,6 @@ export class ObsidianClient {
148
172
  }
149
173
  async listFilesInVault() {
150
174
  return this.safeRequest(async () => {
151
- const requestId = crypto.randomUUID();
152
- console.debug(`[${requestId}] Listing vault files`);
153
175
  const response = await this.client.get("/vault/");
154
176
  return response.data.files;
155
177
  });
@@ -157,8 +179,6 @@ export class ObsidianClient {
157
179
  async listFilesInDir(dirpath) {
158
180
  this.validateFilePath(dirpath);
159
181
  return this.safeRequest(async () => {
160
- const requestId = crypto.randomUUID();
161
- console.debug(`[${requestId}] Listing files in directory: ${dirpath}`);
162
182
  const response = await this.client.get(`/vault/${dirpath}/`);
163
183
  return response.data.files;
164
184
  });
@@ -166,30 +186,22 @@ export class ObsidianClient {
166
186
  async getFileContents(filepath) {
167
187
  this.validateFilePath(filepath);
168
188
  return this.safeRequest(async () => {
169
- const requestId = crypto.randomUUID();
170
- console.debug(`[${requestId}] Getting file contents: ${filepath}`);
171
189
  const response = await this.client.get(`/vault/${filepath}`);
172
190
  return response.data;
173
191
  });
174
192
  }
175
193
  async search(query, contextLength = 100) {
176
194
  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
- });
195
+ const response = await this.client.post("/search/simple/", null, { params: { query, contextLength } });
182
196
  return response.data;
183
197
  });
184
198
  }
185
199
  async appendContent(filepath, content) {
186
200
  this.validateFilePath(filepath);
187
201
  if (!content || typeof content !== 'string') {
188
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
202
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
189
203
  }
190
204
  return this.safeRequest(async () => {
191
- const requestId = crypto.randomUUID();
192
- console.debug(`[${requestId}] Appending content to: ${filepath}`);
193
205
  await this.client.post(`/vault/${filepath}`, content, {
194
206
  headers: {
195
207
  "Content-Type": "text/markdown"
@@ -200,11 +212,9 @@ export class ObsidianClient {
200
212
  async updateContent(filepath, content) {
201
213
  this.validateFilePath(filepath);
202
214
  if (!content || typeof content !== 'string') {
203
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
215
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
204
216
  }
205
217
  return this.safeRequest(async () => {
206
- const requestId = crypto.randomUUID();
207
- console.debug(`[${requestId}] Updating content in: ${filepath}`);
208
218
  await this.client.put(`/vault/${filepath}`, content, {
209
219
  headers: {
210
220
  "Content-Type": "text/markdown"
@@ -214,9 +224,6 @@ export class ObsidianClient {
214
224
  }
215
225
  async searchJson(query) {
216
226
  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
227
  const isTagSearch = JSON.stringify(query).includes('"contains"') &&
221
228
  JSON.stringify(query).includes('"#"');
222
229
  const response = await this.client.post("/search/", query, {
@@ -225,40 +232,29 @@ export class ObsidianClient {
225
232
  "Accept": "application/vnd.olrapi.note+json"
226
233
  }
227
234
  });
228
- if (isTagSearch) {
229
- return response.data;
230
- }
231
- return response.data;
235
+ return isTagSearch ? response.data : response.data;
232
236
  });
233
237
  }
234
238
  async getStatus() {
235
239
  return this.safeRequest(async () => {
236
- const requestId = crypto.randomUUID();
237
- console.debug(`[${requestId}] Getting server status`);
238
240
  const response = await this.client.get("/");
239
241
  return response.data;
240
242
  });
241
243
  }
242
244
  async listCommands() {
243
245
  return this.safeRequest(async () => {
244
- const requestId = crypto.randomUUID();
245
- console.debug(`[${requestId}] Listing available commands`);
246
246
  const response = await this.client.get("/commands/");
247
247
  return response.data.commands;
248
248
  });
249
249
  }
250
250
  async executeCommand(commandId) {
251
251
  return this.safeRequest(async () => {
252
- const requestId = crypto.randomUUID();
253
- console.debug(`[${requestId}] Executing command: ${commandId}`);
254
252
  await this.client.post(`/commands/${commandId}/`);
255
253
  });
256
254
  }
257
255
  async openFile(filepath, newLeaf = false) {
258
256
  this.validateFilePath(filepath);
259
257
  return this.safeRequest(async () => {
260
- const requestId = crypto.randomUUID();
261
- console.debug(`[${requestId}] Opening file: ${filepath}`);
262
258
  await this.client.post(`/open/${filepath}`, null, {
263
259
  params: { newLeaf }
264
260
  });
@@ -266,8 +262,6 @@ export class ObsidianClient {
266
262
  }
267
263
  async getActiveFile() {
268
264
  return this.safeRequest(async () => {
269
- const requestId = crypto.randomUUID();
270
- console.debug(`[${requestId}] Getting active file`);
271
265
  const response = await this.client.get("/active/", {
272
266
  headers: {
273
267
  "Accept": "application/vnd.olrapi.note+json"
@@ -278,8 +272,6 @@ export class ObsidianClient {
278
272
  }
279
273
  async updateActiveFile(content) {
280
274
  return this.safeRequest(async () => {
281
- const requestId = crypto.randomUUID();
282
- console.debug(`[${requestId}] Updating active file`);
283
275
  await this.client.put("/active/", content, {
284
276
  headers: {
285
277
  "Content-Type": "text/markdown"
@@ -289,15 +281,11 @@ export class ObsidianClient {
289
281
  }
290
282
  async deleteActiveFile() {
291
283
  return this.safeRequest(async () => {
292
- const requestId = crypto.randomUUID();
293
- console.debug(`[${requestId}] Deleting active file`);
294
284
  await this.client.delete("/active/");
295
285
  });
296
286
  }
297
287
  async patchActiveFile(operation, targetType, target, content, options) {
298
288
  return this.safeRequest(async () => {
299
- const requestId = crypto.randomUUID();
300
- console.debug(`[${requestId}] Patching active file: ${operation} ${targetType} ${target}`);
301
289
  const headers = {
302
290
  "Operation": operation,
303
291
  "Target-Type": targetType,
@@ -315,8 +303,6 @@ export class ObsidianClient {
315
303
  }
316
304
  async getPeriodicNote(period) {
317
305
  return this.safeRequest(async () => {
318
- const requestId = crypto.randomUUID();
319
- console.debug(`[${requestId}] Getting ${period} note`);
320
306
  const response = await this.client.get(`/periodic/${period}/`, {
321
307
  headers: {
322
308
  "Accept": "application/vnd.olrapi.note+json"
@@ -327,8 +313,6 @@ export class ObsidianClient {
327
313
  }
328
314
  async updatePeriodicNote(period, content) {
329
315
  return this.safeRequest(async () => {
330
- const requestId = crypto.randomUUID();
331
- console.debug(`[${requestId}] Updating ${period} note`);
332
316
  await this.client.put(`/periodic/${period}/`, content, {
333
317
  headers: {
334
318
  "Content-Type": "text/markdown"
@@ -338,15 +322,11 @@ export class ObsidianClient {
338
322
  }
339
323
  async deletePeriodicNote(period) {
340
324
  return this.safeRequest(async () => {
341
- const requestId = crypto.randomUUID();
342
- console.debug(`[${requestId}] Deleting ${period} note`);
343
325
  await this.client.delete(`/periodic/${period}/`);
344
326
  });
345
327
  }
346
328
  async patchPeriodicNote(period, operation, targetType, target, content, options) {
347
329
  return this.safeRequest(async () => {
348
- const requestId = crypto.randomUUID();
349
- console.debug(`[${requestId}] Patching ${period} note: ${operation} ${targetType} ${target}`);
350
330
  const headers = {
351
331
  "Operation": operation,
352
332
  "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.5",
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,51 @@ 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. To fix this:\n` +
175
+ `1. Go to Obsidian Settings > Local REST API\n` +
176
+ `2. Under 'How to Access', copy the certificate\n` +
177
+ `3. Configure the certificate as a trusted certificate authority\n` +
178
+ `4. Ensure you're using HTTPS (HTTP is disabled by default)\n` +
179
+ `Original error: ${error.message}`,
180
+ 50001, // SSL error code
181
+ { code: error.code, config: { verifySSL: this.config.verifySSL } }
182
+ );
183
+ }
184
+
185
+ if (error.code === 'ECONNREFUSED') {
186
+ throw new ObsidianError(
187
+ `Connection refused. To fix this:\n` +
188
+ `1. Ensure Obsidian is running\n` +
189
+ `2. Verify the 'Local REST API' plugin is enabled in Obsidian Settings\n` +
190
+ `3. Check that you're using the correct host (${this.config.host}) and port (${this.config.port})\n` +
191
+ `4. Make sure HTTPS is enabled in the plugin settings`,
192
+ 50002, // Connection refused
193
+ { code: error.code }
194
+ );
195
+ }
196
+
197
+ if (response?.status === 401) {
198
+ throw new ObsidianError(
199
+ `Authentication failed. To fix this:\n` +
200
+ `1. Go to Obsidian Settings > Local REST API\n` +
201
+ `2. Copy your API key from the settings\n` +
202
+ `3. Update your configuration with the new API key\n` +
203
+ `Note: The API key changes when you regenerate certificates`,
204
+ 40100, // Unauthorized
205
+ { code: error.code }
206
+ );
207
+ }
208
+
209
+ // For other errors, use API error code if available
210
+ const errorCode = errorData?.errorCode ?? this.getErrorCode(response?.status ?? 500);
211
+ const message = errorData?.message ?? axiosError.message ?? "Unknown error";
178
212
  throw new ObsidianError(message, errorCode, errorData);
179
213
  }
180
214
 
181
- // For non-Axios errors, use a generic server error code
182
215
  if (error instanceof Error) {
183
216
  throw new ObsidianError(error.message, 50000, error);
184
217
  }
@@ -189,8 +222,6 @@ export class ObsidianClient {
189
222
 
190
223
  async listFilesInVault(): Promise<ObsidianFile[]> {
191
224
  return this.safeRequest(async () => {
192
- const requestId = crypto.randomUUID();
193
- console.debug(`[${requestId}] Listing vault files`);
194
225
  const response = await this.client.get<{ files: ObsidianFile[] }>("/vault/");
195
226
  return response.data.files;
196
227
  });
@@ -199,8 +230,6 @@ export class ObsidianClient {
199
230
  async listFilesInDir(dirpath: string): Promise<ObsidianFile[]> {
200
231
  this.validateFilePath(dirpath);
201
232
  return this.safeRequest(async () => {
202
- const requestId = crypto.randomUUID();
203
- console.debug(`[${requestId}] Listing files in directory: ${dirpath}`);
204
233
  const response = await this.client.get<{ files: ObsidianFile[] }>(`/vault/${dirpath}/`);
205
234
  return response.data.files;
206
235
  });
@@ -209,8 +238,6 @@ export class ObsidianClient {
209
238
  async getFileContents(filepath: string): Promise<string> {
210
239
  this.validateFilePath(filepath);
211
240
  return this.safeRequest(async () => {
212
- const requestId = crypto.randomUUID();
213
- console.debug(`[${requestId}] Getting file contents: ${filepath}`);
214
241
  const response = await this.client.get<string>(`/vault/${filepath}`);
215
242
  return response.data;
216
243
  });
@@ -218,14 +245,10 @@ export class ObsidianClient {
218
245
 
219
246
  async search(query: string, contextLength: number = 100): Promise<SimpleSearchResult[]> {
220
247
  return this.safeRequest(async () => {
221
- const requestId = crypto.randomUUID();
222
- console.debug(`[${requestId}] Performing simple search: ${query}`);
223
248
  const response = await this.client.post<SimpleSearchResult[]>(
224
249
  "/search/simple/",
225
250
  null,
226
- {
227
- params: { query, contextLength }
228
- }
251
+ { params: { query, contextLength } }
229
252
  );
230
253
  return response.data;
231
254
  });
@@ -234,11 +257,9 @@ export class ObsidianClient {
234
257
  async appendContent(filepath: string, content: string): Promise<void> {
235
258
  this.validateFilePath(filepath);
236
259
  if (!content || typeof content !== 'string') {
237
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
260
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
238
261
  }
239
262
  return this.safeRequest(async () => {
240
- const requestId = crypto.randomUUID();
241
- console.debug(`[${requestId}] Appending content to: ${filepath}`);
242
263
  await this.client.post(
243
264
  `/vault/${filepath}`,
244
265
  content,
@@ -254,12 +275,10 @@ export class ObsidianClient {
254
275
  async updateContent(filepath: string, content: string): Promise<void> {
255
276
  this.validateFilePath(filepath);
256
277
  if (!content || typeof content !== 'string') {
257
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
278
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
258
279
  }
259
280
 
260
281
  return this.safeRequest(async () => {
261
- const requestId = crypto.randomUUID();
262
- console.debug(`[${requestId}] Updating content in: ${filepath}`);
263
282
  await this.client.put(
264
283
  `/vault/${filepath}`,
265
284
  content,
@@ -274,10 +293,6 @@ export class ObsidianClient {
274
293
 
275
294
  async searchJson(query: JsonLogicQuery): Promise<SearchResponse[]> {
276
295
  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
296
  const isTagSearch = JSON.stringify(query).includes('"contains"') &&
282
297
  JSON.stringify(query).includes('"#"');
283
298
 
@@ -292,17 +307,12 @@ export class ObsidianClient {
292
307
  }
293
308
  );
294
309
 
295
- if (isTagSearch) {
296
- return response.data as SimpleSearchResult[];
297
- }
298
- return response.data as SearchResult[];
310
+ return isTagSearch ? response.data as SimpleSearchResult[] : response.data as SearchResult[];
299
311
  });
300
312
  }
301
313
 
302
314
  async getStatus(): Promise<ObsidianStatus> {
303
315
  return this.safeRequest(async () => {
304
- const requestId = crypto.randomUUID();
305
- console.debug(`[${requestId}] Getting server status`);
306
316
  const response = await this.client.get<ObsidianStatus>("/");
307
317
  return response.data;
308
318
  });
@@ -310,8 +320,6 @@ export class ObsidianClient {
310
320
 
311
321
  async listCommands(): Promise<ObsidianCommand[]> {
312
322
  return this.safeRequest(async () => {
313
- const requestId = crypto.randomUUID();
314
- console.debug(`[${requestId}] Listing available commands`);
315
323
  const response = await this.client.get<{commands: ObsidianCommand[]}>("/commands/");
316
324
  return response.data.commands;
317
325
  });
@@ -319,8 +327,6 @@ export class ObsidianClient {
319
327
 
320
328
  async executeCommand(commandId: string): Promise<void> {
321
329
  return this.safeRequest(async () => {
322
- const requestId = crypto.randomUUID();
323
- console.debug(`[${requestId}] Executing command: ${commandId}`);
324
330
  await this.client.post(`/commands/${commandId}/`);
325
331
  });
326
332
  }
@@ -328,8 +334,6 @@ export class ObsidianClient {
328
334
  async openFile(filepath: string, newLeaf: boolean = false): Promise<void> {
329
335
  this.validateFilePath(filepath);
330
336
  return this.safeRequest(async () => {
331
- const requestId = crypto.randomUUID();
332
- console.debug(`[${requestId}] Opening file: ${filepath}`);
333
337
  await this.client.post(`/open/${filepath}`, null, {
334
338
  params: { newLeaf }
335
339
  });
@@ -338,8 +342,6 @@ export class ObsidianClient {
338
342
 
339
343
  async getActiveFile(): Promise<NoteJson> {
340
344
  return this.safeRequest(async () => {
341
- const requestId = crypto.randomUUID();
342
- console.debug(`[${requestId}] Getting active file`);
343
345
  const response = await this.client.get<NoteJson>("/active/", {
344
346
  headers: {
345
347
  "Accept": "application/vnd.olrapi.note+json"
@@ -351,8 +353,6 @@ export class ObsidianClient {
351
353
 
352
354
  async updateActiveFile(content: string): Promise<void> {
353
355
  return this.safeRequest(async () => {
354
- const requestId = crypto.randomUUID();
355
- console.debug(`[${requestId}] Updating active file`);
356
356
  await this.client.put("/active/", content, {
357
357
  headers: {
358
358
  "Content-Type": "text/markdown"
@@ -363,21 +363,22 @@ export class ObsidianClient {
363
363
 
364
364
  async deleteActiveFile(): Promise<void> {
365
365
  return this.safeRequest(async () => {
366
- const requestId = crypto.randomUUID();
367
- console.debug(`[${requestId}] Deleting active file`);
368
366
  await this.client.delete("/active/");
369
367
  });
370
368
  }
371
369
 
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> {
370
+ async patchActiveFile(
371
+ operation: "append" | "prepend" | "replace",
372
+ targetType: "heading" | "block" | "frontmatter",
373
+ target: string,
374
+ content: string,
375
+ options?: {
376
+ delimiter?: string;
377
+ trimWhitespace?: boolean;
378
+ contentType?: "text/markdown" | "application/json";
379
+ }
380
+ ): Promise<void> {
377
381
  return this.safeRequest(async () => {
378
- const requestId = crypto.randomUUID();
379
- console.debug(`[${requestId}] Patching active file: ${operation} ${targetType} ${target}`);
380
-
381
382
  const headers: Record<string, string> = {
382
383
  "Operation": operation,
383
384
  "Target-Type": targetType,
@@ -398,8 +399,6 @@ export class ObsidianClient {
398
399
 
399
400
  async getPeriodicNote(period: PeriodType["type"]): Promise<NoteJson> {
400
401
  return this.safeRequest(async () => {
401
- const requestId = crypto.randomUUID();
402
- console.debug(`[${requestId}] Getting ${period} note`);
403
402
  const response = await this.client.get<NoteJson>(`/periodic/${period}/`, {
404
403
  headers: {
405
404
  "Accept": "application/vnd.olrapi.note+json"
@@ -411,8 +410,6 @@ export class ObsidianClient {
411
410
 
412
411
  async updatePeriodicNote(period: PeriodType["type"], content: string): Promise<void> {
413
412
  return this.safeRequest(async () => {
414
- const requestId = crypto.randomUUID();
415
- console.debug(`[${requestId}] Updating ${period} note`);
416
413
  await this.client.put(`/periodic/${period}/`, content, {
417
414
  headers: {
418
415
  "Content-Type": "text/markdown"
@@ -423,21 +420,23 @@ export class ObsidianClient {
423
420
 
424
421
  async deletePeriodicNote(period: PeriodType["type"]): Promise<void> {
425
422
  return this.safeRequest(async () => {
426
- const requestId = crypto.randomUUID();
427
- console.debug(`[${requestId}] Deleting ${period} note`);
428
423
  await this.client.delete(`/periodic/${period}/`);
429
424
  });
430
425
  }
431
426
 
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> {
427
+ async patchPeriodicNote(
428
+ period: PeriodType["type"],
429
+ operation: "append" | "prepend" | "replace",
430
+ targetType: "heading" | "block" | "frontmatter",
431
+ target: string,
432
+ content: string,
433
+ options?: {
434
+ delimiter?: string;
435
+ trimWhitespace?: boolean;
436
+ contentType?: "text/markdown" | "application/json";
437
+ }
438
+ ): Promise<void> {
437
439
  return this.safeRequest(async () => {
438
- const requestId = crypto.randomUUID();
439
- console.debug(`[${requestId}] Patching ${period} note: ${operation} ${targetType} ${target}`);
440
-
441
440
  const headers: Record<string, string> = {
442
441
  "Operation": operation,
443
442
  "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 {