obsidian-mcp-server 1.2.2 → 1.2.4

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.3-blue.svg)](https://www.typescriptlang.org/)
4
4
  [![Model Context Protocol](https://img.shields.io/badge/MCP-1.4.0-green.svg)](https://modelcontextprotocol.io/)
5
- [![Version](https://img.shields.io/badge/Version-1.2.2-blue.svg)]()
5
+ [![Version](https://img.shields.io/badge/Version-1.2.3-blue.svg)]()
6
6
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
7
7
  [![Status](https://img.shields.io/badge/Status-Stable-blue.svg)]()
8
8
  [![GitHub](https://img.shields.io/github/stars/cyanheads/obsidian-mcp-server?style=social)](https://github.com/cyanheads/obsidian-mcp-server)
@@ -20,8 +20,9 @@ Requires the Local REST API plugin in Obsidian.
20
20
  - Resource monitoring and cleanup
21
21
 
22
22
  ### Search System
23
- - Full-text and JsonLogic search with context control
24
- - Optimized query processing with token limits
23
+ - Full-text search with configurable context
24
+ - Advanced JsonLogic queries for files, tags, and metadata
25
+ - Support for glob patterns and frontmatter fields
25
26
 
26
27
  ### Property Management
27
28
  - YAML frontmatter parsing and intelligent merging
@@ -75,6 +76,28 @@ Environment configuration:
75
76
  - `MAX_TOKENS`: Maximum tokens per response (default: 20000)
76
77
  - `TOOL_TIMEOUT_MS`: Tool execution timeout (default: 60000)
77
78
 
79
+ Additional configuration options:
80
+ ```typescript
81
+ interface ObsidianConfig {
82
+ apiKey: string; // Required: API key for authentication
83
+ verifySSL?: boolean; // Optional: Enable SSL verification
84
+ timeout?: number; // Optional: Request timeout in ms
85
+ maxContentLength?: number;// Optional: Max response content length
86
+ maxBodyLength?: number; // Optional: Max request body length
87
+ }
88
+
89
+ interface RateLimitConfig {
90
+ windowMs: number; // Time window for rate limiting
91
+ maxRequests: number; // Max requests per window
92
+ }
93
+ ```
94
+
95
+ Error Handling:
96
+ - All errors include a 5-digit error code
97
+ - HTTP status codes are automatically converted (e.g., 404 -> 40400)
98
+ - Default server error code: 50000
99
+ - Detailed error messages include original error stack traces in development
100
+
78
101
  ## Tools
79
102
 
80
103
  ### File Management
@@ -103,7 +126,19 @@ obsidian_find_in_file: {
103
126
 
104
127
  // Advanced search with JsonLogic
105
128
  obsidian_complex_search: {
106
- query: JsonLogicQuery // Example: {"glob": ["*.md", {"var": "path"}]}
129
+ query: JsonLogicQuery
130
+ // Examples:
131
+ // Find by tag:
132
+ // {"in": ["#mytag", {"var": "frontmatter.tags"}]}
133
+ //
134
+ // Find markdown files in a directory:
135
+ // {"glob": ["docs/*.md", {"var": "path"}]}
136
+ //
137
+ // Combine conditions:
138
+ // {"and": [
139
+ // {"glob": ["*.md", {"var": "path"}]},
140
+ // {"in": ["#mytag", {"var": "frontmatter.tags"}]}
141
+ // ]}
107
142
  }
108
143
  ```
109
144
 
@@ -122,8 +157,41 @@ obsidian_patch_content: {
122
157
  }
123
158
  ```
124
159
 
160
+ ### Command Management
161
+ ```typescript
162
+ // List available commands
163
+ obsidian_list_commands: {}
164
+
165
+ // Execute a command
166
+ obsidian_execute_command: {
167
+ commandId: string // Command ID to execute
168
+ }
169
+ ```
170
+
171
+ ### File Navigation
172
+ ```typescript
173
+ // Open a file in Obsidian
174
+ obsidian_open_file: {
175
+ filepath: string, // Path relative to vault root
176
+ newLeaf?: boolean // Open in new leaf (default: false)
177
+ }
178
+
179
+ // Get active file content
180
+ obsidian_get_active_file: {}
181
+
182
+ // Get periodic note content
183
+ obsidian_get_periodic_note: {
184
+ period: "daily" | "weekly" | "monthly" | "quarterly" | "yearly"
185
+ }
186
+ ```
187
+
125
188
  ### Property Management
126
189
  ```typescript
190
+ // Get all tags in vault or directory
191
+ obsidian_get_tags: {
192
+ path?: string // Optional: limit to specific directory
193
+ }
194
+
127
195
  // Get note properties
128
196
  obsidian_get_properties: {
129
197
  filepath: string // Path relative to vault root
@@ -160,8 +228,10 @@ obsidian_update_properties: {
160
228
  - Handle errors and monitor performance
161
229
 
162
230
  ### Search Implementation
163
- - Optimize queries and control context size
164
- - Handle large results within token limits
231
+ - Use appropriate search tool for the task:
232
+ - obsidian_find_in_file for text search
233
+ - obsidian_complex_search for metadata/tag filtering
234
+ - Keep context size reasonable (default: 10 chars)
165
235
 
166
236
  ### Property Management
167
237
  - Use appropriate types and validate updates
package/build/obsidian.js CHANGED
@@ -23,14 +23,14 @@ export class ObsidianClient {
23
23
  config;
24
24
  constructor(config) {
25
25
  if (!config.apiKey) {
26
- throw new ObsidianError("API key is required", 401);
26
+ throw new ObsidianError("API key is required", 40100); // 40100 = Unauthorized
27
27
  }
28
28
  // Combine defaults with provided config
29
29
  this.config = {
30
30
  ...DEFAULT_OBSIDIAN_CONFIG,
31
31
  verifySSL: config.verifySSL ?? process.env.NODE_ENV === 'production', // Enable SSL verification in production by default
32
32
  apiKey: config.apiKey,
33
- timeout: config.timeout ?? 5000,
33
+ timeout: config.timeout ?? 5000, // 5 second default timeout
34
34
  maxContentLength: config.maxContentLength ?? 50 * 1024 * 1024, // 50MB
35
35
  maxBodyLength: config.maxBodyLength ?? 50 * 1024 * 1024 // 50MB
36
36
  };
@@ -88,11 +88,37 @@ export class ObsidianClient {
88
88
  // Prevent path traversal attacks
89
89
  const normalizedPath = filepath.replace(/\\/g, '/');
90
90
  if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
91
- throw new ObsidianError('Invalid file path: Path traversal not allowed', 400);
91
+ throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001); // 40001 = Path traversal error
92
92
  }
93
93
  // Additional path validations
94
94
  if (normalizedPath.startsWith('/') || /^[a-zA-Z]:/.test(normalizedPath)) {
95
- throw new ObsidianError('Invalid file path: Absolute paths not allowed', 400);
95
+ throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002); // 40002 = Invalid path format
96
+ }
97
+ }
98
+ getErrorCode(status) {
99
+ // Convert HTTP status codes to 5-digit error codes
100
+ switch (status) {
101
+ // Client errors (400-499)
102
+ case 400: return 40000; // Bad request
103
+ case 401: return 40100; // Unauthorized
104
+ case 403: return 40300; // Forbidden
105
+ case 404: return 40400; // Not found
106
+ case 405: return 40500; // Method not allowed
107
+ case 409: return 40900; // Conflict
108
+ case 429: return 42900; // Too many requests
109
+ // Server errors (500-599)
110
+ case 500: return 50000; // Internal server error
111
+ case 501: return 50100; // Not implemented
112
+ case 502: return 50200; // Bad gateway
113
+ case 503: return 50300; // Service unavailable
114
+ case 504: return 50400; // Gateway timeout
115
+ // Default cases
116
+ default:
117
+ if (status >= 400 && status < 500)
118
+ return 40000 + (status - 400) * 100;
119
+ if (status >= 500 && status < 600)
120
+ return 50000 + (status - 500) * 100;
121
+ return 50000; // Default to internal server error
96
122
  }
97
123
  }
98
124
  async safeRequest(operation) {
@@ -104,11 +130,20 @@ export class ObsidianClient {
104
130
  const axiosError = error;
105
131
  const response = axiosError.response;
106
132
  const errorData = response?.data;
107
- const code = errorData?.errorCode ?? response?.status ?? 500;
108
- const message = errorData?.message ?? axiosError.message ?? "Unknown error";
109
- throw new ObsidianError(message, code, errorData);
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";
140
+ throw new ObsidianError(message, errorCode, errorData);
141
+ }
142
+ // For non-Axios errors, use a generic server error code
143
+ if (error instanceof Error) {
144
+ throw new ObsidianError(error.message, 50000, error);
110
145
  }
111
- throw error;
146
+ throw new ObsidianError("Unknown error occurred", 50000, error);
112
147
  }
113
148
  }
114
149
  async listFilesInVault() {
@@ -141,11 +176,8 @@ export class ObsidianClient {
141
176
  return this.safeRequest(async () => {
142
177
  const requestId = crypto.randomUUID();
143
178
  console.debug(`[${requestId}] Performing simple search: ${query}`);
144
- const response = await this.client.post("/search/simple/", undefined, {
145
- params: {
146
- query,
147
- contextLength
148
- }
179
+ const response = await this.client.post("/search/simple/", null, {
180
+ params: { query, contextLength }
149
181
  });
150
182
  return response.data;
151
183
  });
@@ -153,7 +185,7 @@ export class ObsidianClient {
153
185
  async appendContent(filepath, content) {
154
186
  this.validateFilePath(filepath);
155
187
  if (!content || typeof content !== 'string') {
156
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 400);
188
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
157
189
  }
158
190
  return this.safeRequest(async () => {
159
191
  const requestId = crypto.randomUUID();
@@ -168,7 +200,7 @@ export class ObsidianClient {
168
200
  async updateContent(filepath, content) {
169
201
  this.validateFilePath(filepath);
170
202
  if (!content || typeof content !== 'string') {
171
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 400);
203
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
172
204
  }
173
205
  return this.safeRequest(async () => {
174
206
  const requestId = crypto.randomUUID();
@@ -184,14 +216,151 @@ export class ObsidianClient {
184
216
  return this.safeRequest(async () => {
185
217
  const requestId = crypto.randomUUID();
186
218
  console.debug(`[${requestId}] Performing complex search with query:`, JSON.stringify(query));
219
+ // Check if this is a tag-based search
220
+ const isTagSearch = JSON.stringify(query).includes('"contains"') &&
221
+ JSON.stringify(query).includes('"#"');
187
222
  const response = await this.client.post("/search/", query, {
188
223
  headers: {
189
224
  "Content-Type": "application/vnd.olrapi.jsonlogic+json",
190
- "Accept": "application/json"
225
+ "Accept": "application/vnd.olrapi.note+json"
226
+ }
227
+ });
228
+ if (isTagSearch) {
229
+ return response.data;
230
+ }
231
+ return response.data;
232
+ });
233
+ }
234
+ async getStatus() {
235
+ return this.safeRequest(async () => {
236
+ const requestId = crypto.randomUUID();
237
+ console.debug(`[${requestId}] Getting server status`);
238
+ const response = await this.client.get("/");
239
+ return response.data;
240
+ });
241
+ }
242
+ async listCommands() {
243
+ return this.safeRequest(async () => {
244
+ const requestId = crypto.randomUUID();
245
+ console.debug(`[${requestId}] Listing available commands`);
246
+ const response = await this.client.get("/commands/");
247
+ return response.data.commands;
248
+ });
249
+ }
250
+ async executeCommand(commandId) {
251
+ return this.safeRequest(async () => {
252
+ const requestId = crypto.randomUUID();
253
+ console.debug(`[${requestId}] Executing command: ${commandId}`);
254
+ await this.client.post(`/commands/${commandId}/`);
255
+ });
256
+ }
257
+ async openFile(filepath, newLeaf = false) {
258
+ this.validateFilePath(filepath);
259
+ return this.safeRequest(async () => {
260
+ const requestId = crypto.randomUUID();
261
+ console.debug(`[${requestId}] Opening file: ${filepath}`);
262
+ await this.client.post(`/open/${filepath}`, null, {
263
+ params: { newLeaf }
264
+ });
265
+ });
266
+ }
267
+ async getActiveFile() {
268
+ return this.safeRequest(async () => {
269
+ const requestId = crypto.randomUUID();
270
+ console.debug(`[${requestId}] Getting active file`);
271
+ const response = await this.client.get("/active/", {
272
+ headers: {
273
+ "Accept": "application/vnd.olrapi.note+json"
274
+ }
275
+ });
276
+ return response.data;
277
+ });
278
+ }
279
+ async updateActiveFile(content) {
280
+ return this.safeRequest(async () => {
281
+ const requestId = crypto.randomUUID();
282
+ console.debug(`[${requestId}] Updating active file`);
283
+ await this.client.put("/active/", content, {
284
+ headers: {
285
+ "Content-Type": "text/markdown"
286
+ }
287
+ });
288
+ });
289
+ }
290
+ async deleteActiveFile() {
291
+ return this.safeRequest(async () => {
292
+ const requestId = crypto.randomUUID();
293
+ console.debug(`[${requestId}] Deleting active file`);
294
+ await this.client.delete("/active/");
295
+ });
296
+ }
297
+ async patchActiveFile(operation, targetType, target, content, options) {
298
+ return this.safeRequest(async () => {
299
+ const requestId = crypto.randomUUID();
300
+ console.debug(`[${requestId}] Patching active file: ${operation} ${targetType} ${target}`);
301
+ const headers = {
302
+ "Operation": operation,
303
+ "Target-Type": targetType,
304
+ "Target": target,
305
+ "Content-Type": options?.contentType || "text/markdown"
306
+ };
307
+ if (options?.delimiter) {
308
+ headers["Target-Delimiter"] = options.delimiter;
309
+ }
310
+ if (options?.trimWhitespace !== undefined) {
311
+ headers["Trim-Target-Whitespace"] = options.trimWhitespace.toString();
312
+ }
313
+ await this.client.patch("/active/", content, { headers });
314
+ });
315
+ }
316
+ async getPeriodicNote(period) {
317
+ return this.safeRequest(async () => {
318
+ const requestId = crypto.randomUUID();
319
+ console.debug(`[${requestId}] Getting ${period} note`);
320
+ const response = await this.client.get(`/periodic/${period}/`, {
321
+ headers: {
322
+ "Accept": "application/vnd.olrapi.note+json"
191
323
  }
192
324
  });
193
325
  return response.data;
194
326
  });
195
327
  }
328
+ async updatePeriodicNote(period, content) {
329
+ return this.safeRequest(async () => {
330
+ const requestId = crypto.randomUUID();
331
+ console.debug(`[${requestId}] Updating ${period} note`);
332
+ await this.client.put(`/periodic/${period}/`, content, {
333
+ headers: {
334
+ "Content-Type": "text/markdown"
335
+ }
336
+ });
337
+ });
338
+ }
339
+ async deletePeriodicNote(period) {
340
+ return this.safeRequest(async () => {
341
+ const requestId = crypto.randomUUID();
342
+ console.debug(`[${requestId}] Deleting ${period} note`);
343
+ await this.client.delete(`/periodic/${period}/`);
344
+ });
345
+ }
346
+ async patchPeriodicNote(period, operation, targetType, target, content, options) {
347
+ return this.safeRequest(async () => {
348
+ const requestId = crypto.randomUUID();
349
+ console.debug(`[${requestId}] Patching ${period} note: ${operation} ${targetType} ${target}`);
350
+ const headers = {
351
+ "Operation": operation,
352
+ "Target-Type": targetType,
353
+ "Target": target,
354
+ "Content-Type": options?.contentType || "text/markdown"
355
+ };
356
+ if (options?.delimiter) {
357
+ headers["Target-Delimiter"] = options.delimiter;
358
+ }
359
+ if (options?.trimWhitespace !== undefined) {
360
+ headers["Trim-Target-Whitespace"] = options.trimWhitespace.toString();
361
+ }
362
+ await this.client.patch(`/periodic/${period}/`, content, { headers });
363
+ });
364
+ }
196
365
  }
197
366
  //# sourceMappingURL=obsidian.js.map
@@ -1,5 +1,5 @@
1
1
  import { parse, stringify } from 'yaml';
2
- import { ObsidianPropertiesSchema } from './propertyTypes.js';
2
+ import { ObsidianPropertiesSchema, PropertyUpdateSchema } from './propertyTypes.js';
3
3
  export class PropertyManager {
4
4
  client;
5
5
  constructor(client) {
@@ -51,7 +51,7 @@ export class PropertyManager {
51
51
  * Validate property values
52
52
  */
53
53
  validateProperties(properties) {
54
- const result = ObsidianPropertiesSchema.safeParse(properties);
54
+ const result = PropertyUpdateSchema.safeParse(properties);
55
55
  if (result.success) {
56
56
  return { valid: true, errors: [] };
57
57
  }
@@ -63,17 +63,18 @@ export class PropertyManager {
63
63
  /**
64
64
  * Merge new properties with existing ones
65
65
  */
66
- mergeProperties(existing, updates) {
66
+ mergeProperties(existing, updates, replace = false) {
67
67
  const merged = { ...existing };
68
68
  for (const [key, value] of Object.entries(updates)) {
69
- if (value === undefined)
69
+ // Skip undefined values and timestamp fields
70
+ if (value === undefined || key === 'created' || key === 'modified')
70
71
  continue;
71
72
  const currentValue = merged[key];
72
- // Special handling for arrays - merge rather than replace
73
+ // Handle arrays based on replace flag
73
74
  if (Array.isArray(value) && Array.isArray(currentValue)) {
74
- merged[key] = [
75
- ...new Set([...currentValue, ...value])
76
- ];
75
+ merged[key] = replace ?
76
+ value :
77
+ [...new Set([...currentValue, ...value])];
77
78
  }
78
79
  // Special handling for custom object - deep merge
79
80
  else if (key === 'custom' && typeof value === 'object' && value !== null) {
@@ -87,7 +88,7 @@ export class PropertyManager {
87
88
  merged[key] = value;
88
89
  }
89
90
  }
90
- // Always update modified date
91
+ // Always update modified date (this is the only place we set it)
91
92
  merged.modified = new Date().toISOString();
92
93
  return merged;
93
94
  }
@@ -115,7 +116,7 @@ export class PropertyManager {
115
116
  /**
116
117
  * Update properties of a note
117
118
  */
118
- async updateProperties(filepath, newProperties) {
119
+ async updateProperties(filepath, newProperties, replace = false) {
119
120
  try {
120
121
  // Validate new properties
121
122
  const validation = this.validateProperties(newProperties);
@@ -130,7 +131,7 @@ export class PropertyManager {
130
131
  const content = await this.client.getFileContents(filepath);
131
132
  const existingProperties = this.parseProperties(content);
132
133
  // Merge properties
133
- const mergedProperties = this.mergeProperties(existingProperties, newProperties);
134
+ const mergedProperties = this.mergeProperties(existingProperties, newProperties, replace);
134
135
  // Generate new frontmatter
135
136
  const newFrontmatter = this.generateProperties(mergedProperties);
136
137
  // Replace existing frontmatter or prepend to file
@@ -60,7 +60,7 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler {
60
60
  getToolDescription() {
61
61
  return {
62
62
  name: this.name,
63
- description: "Update properties in an Obsidian note's YAML frontmatter. Intelligently merges arrays (tags, type, status), handles custom fields, and automatically updates the modified timestamp. Existing properties not included in the update are preserved.",
63
+ description: "Update properties in an Obsidian note's YAML frontmatter. Intelligently merges arrays (tags, type, status), handles custom fields, and automatically manages timestamps. Valid property types:\n- type: Any string value\n- status: ['draft', 'in-progress', 'review', 'complete']\n- tags: Array of strings starting with '#'\n- Other fields: title, author, version, platform, repository (URI), dependencies, sources, urls (URI), papers, custom (object)",
64
64
  examples: [
65
65
  {
66
66
  description: "Update basic metadata",
@@ -74,14 +74,14 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler {
74
74
  }
75
75
  },
76
76
  {
77
- description: "Update tags and status",
77
+ description: "Update tags and status with replace",
78
78
  args: {
79
79
  filepath: "docs/feature.md",
80
80
  properties: {
81
81
  tags: ["#feature", "#in-development", "#high-priority"],
82
- status: ["in-progress"],
83
- version: "2.0.0"
84
- }
82
+ status: ["in-progress"]
83
+ },
84
+ replace: true
85
85
  }
86
86
  },
87
87
  {
@@ -111,25 +111,10 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler {
111
111
  description: "Properties to update",
112
112
  properties: {
113
113
  title: { type: "string" },
114
- created: { type: "string", format: "date-time" },
115
- modified: { type: "string", format: "date-time" },
116
114
  author: { type: "string" },
117
115
  type: {
118
116
  type: "array",
119
- items: {
120
- type: "string",
121
- enum: [
122
- "concept",
123
- "architecture",
124
- "specification",
125
- "protocol",
126
- "api",
127
- "research",
128
- "implementation",
129
- "guide",
130
- "reference"
131
- ]
132
- }
117
+ items: { type: "string" }
133
118
  },
134
119
  tags: {
135
120
  type: "array",
@@ -167,6 +152,10 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler {
167
152
  }
168
153
  },
169
154
  additionalProperties: false
155
+ },
156
+ replace: {
157
+ type: "boolean",
158
+ description: "If true, arrays will be replaced instead of merged"
170
159
  }
171
160
  },
172
161
  required: ["filepath", "properties"]
@@ -175,7 +164,7 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler {
175
164
  }
176
165
  async runTool(args) {
177
166
  try {
178
- const result = await this.propertyManager.updateProperties(args.filepath, args.properties);
167
+ const result = await this.propertyManager.updateProperties(args.filepath, args.properties, args.replace);
179
168
  return this.createResponse(result);
180
169
  }
181
170
  catch (error) {
@@ -1,30 +1,44 @@
1
1
  import { z } from "zod";
2
2
  // Define validation schemas
3
- export const PropertyTypeEnum = z.enum([
4
- "concept",
5
- "architecture",
6
- "specification",
7
- "protocol",
8
- "api",
9
- "research",
10
- "implementation",
11
- "guide",
12
- "reference"
13
- ]);
3
+ // Allow any string for type to be more flexible
4
+ export const PropertyType = z.string();
14
5
  export const StatusEnum = z.enum([
15
6
  "draft",
16
7
  "in-progress",
17
8
  "review",
18
9
  "complete"
19
10
  ]);
11
+ // Schema for reading properties (includes timestamps)
20
12
  export const ObsidianPropertiesSchema = z.object({
13
+ // Basic Metadata
14
+ // Note: Timestamps are managed automatically
15
+ title: z.string().optional(),
16
+ modified: z.string().datetime().optional(), // Read-only, managed by MCP server
17
+ author: z.string().optional(),
18
+ // Classification
19
+ type: z.array(PropertyType).optional(),
20
+ // Organization
21
+ tags: z.array(z.string().startsWith("#")).optional(),
22
+ // Technical Metadata
23
+ status: z.array(StatusEnum).optional(),
24
+ version: z.string().optional(),
25
+ platform: z.string().optional(),
26
+ repository: z.string().url().optional(),
27
+ dependencies: z.array(z.string()).optional(),
28
+ // References
29
+ sources: z.array(z.string()).optional(),
30
+ urls: z.array(z.string().url()).optional(),
31
+ papers: z.array(z.string()).optional(),
32
+ // Custom Fields
33
+ custom: z.record(z.unknown()).optional()
34
+ });
35
+ // Schema for validating property updates (excludes timestamps)
36
+ export const PropertyUpdateSchema = z.object({
21
37
  // Basic Metadata
22
38
  title: z.string().optional(),
23
- created: z.string().datetime().optional(),
24
- modified: z.string().datetime().optional(),
25
39
  author: z.string().optional(),
26
40
  // Classification
27
- type: z.array(PropertyTypeEnum).optional(),
41
+ type: z.array(PropertyType).optional(),
28
42
  // Organization
29
43
  tags: z.array(z.string().startsWith("#")).optional(),
30
44
  // Technical Metadata
@@ -0,0 +1,69 @@
1
+ export class TagResource {
2
+ client;
3
+ static TAG_PATTERN = /#[a-zA-Z0-9_-]+/g;
4
+ constructor(client) {
5
+ this.client = client;
6
+ }
7
+ getResourceDescription() {
8
+ return {
9
+ uri: "obsidian://tags",
10
+ name: "Obsidian Tags",
11
+ description: "List of all tags used across the Obsidian vault with their usage counts",
12
+ mimeType: "application/json"
13
+ };
14
+ }
15
+ async getContent() {
16
+ try {
17
+ // Search for files containing #
18
+ const query = {
19
+ "contains": [{ "var": "content" }, "#"]
20
+ };
21
+ 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
+ }
40
+ });
41
+ }
42
+ });
43
+ // Convert to sorted response format
44
+ const response = {
45
+ tags: Array.from(tagMap.entries())
46
+ .map(([name, files]) => ({
47
+ name,
48
+ count: files.size,
49
+ files: Array.from(files).sort()
50
+ }))
51
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
52
+ metadata: {
53
+ totalOccurrences,
54
+ uniqueTags: tagMap.size,
55
+ scannedFiles
56
+ }
57
+ };
58
+ return [{
59
+ type: "text",
60
+ text: JSON.stringify(response, null, 2)
61
+ }];
62
+ }
63
+ catch (error) {
64
+ console.error("Failed to fetch tags:", error);
65
+ throw error;
66
+ }
67
+ }
68
+ }
69
+ //# sourceMappingURL=resources.js.map