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 +76 -6
- package/build/obsidian.js +185 -16
- package/build/properties.js +12 -11
- package/build/propertyTools.js +11 -22
- package/build/propertyTypes.js +28 -14
- package/build/resources.js +69 -0
- package/build/server.js +36 -12
- package/build/tools.js +424 -48
- package/build/types.js +20 -3
- package/package.json +3 -3
- package/src/obsidian.ts +244 -21
- package/src/properties.ts +19 -15
- package/src/propertyTools.ts +13 -22
- package/src/propertyTypes.ts +36 -14
- package/src/resources.ts +76 -0
- package/src/server.ts +39 -14
- package/src/tools.ts +460 -49
- package/src/types.ts +87 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.typescriptlang.org/)
|
|
4
4
|
[](https://modelcontextprotocol.io/)
|
|
5
|
-
[]()
|
|
6
6
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
7
7
|
[]()
|
|
8
8
|
[](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
|
|
24
|
-
-
|
|
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
|
|
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
|
-
-
|
|
164
|
-
-
|
|
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",
|
|
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',
|
|
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',
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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/",
|
|
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',
|
|
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',
|
|
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
|
package/build/properties.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
73
|
+
// Handle arrays based on replace flag
|
|
73
74
|
if (Array.isArray(value) && Array.isArray(currentValue)) {
|
|
74
|
-
merged[key] =
|
|
75
|
-
|
|
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
|
package/build/propertyTools.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) {
|
package/build/propertyTypes.js
CHANGED
|
@@ -1,30 +1,44 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
// Define validation schemas
|
|
3
|
-
|
|
4
|
-
|
|
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(
|
|
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
|