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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "obsidian-mcp-server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.4",
|
|
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",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"format": "prettier --write \"src/**/*.ts\""
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@modelcontextprotocol/sdk": "^1.4.
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.4.1",
|
|
22
22
|
"axios": "^1.7.9",
|
|
23
23
|
"dotenv": "^16.4.7",
|
|
24
24
|
"tiktoken": "^1.0.18",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"@types/node": "^22.10.10",
|
|
30
30
|
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
|
31
31
|
"@typescript-eslint/parser": "^8.21.0",
|
|
32
|
-
"eslint": "^9.
|
|
32
|
+
"eslint": "^9.19.0",
|
|
33
33
|
"eslint-config-prettier": "^10.0.1",
|
|
34
34
|
"eslint-plugin-prettier": "^5.2.3",
|
|
35
35
|
"prettier": "^3.4.2",
|
package/src/obsidian.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
2
|
import type { AxiosInstance, AxiosError, AxiosRequestConfig } from "axios";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ObsidianConfig,
|
|
5
|
+
ObsidianError,
|
|
6
|
+
ObsidianFile,
|
|
7
|
+
SearchResult,
|
|
8
|
+
SimpleSearchResult,
|
|
9
|
+
SearchResponse,
|
|
10
|
+
DEFAULT_OBSIDIAN_CONFIG,
|
|
11
|
+
ObsidianServerConfig,
|
|
12
|
+
JsonLogicQuery,
|
|
13
|
+
ObsidianStatus,
|
|
14
|
+
ObsidianCommand,
|
|
15
|
+
NoteJson,
|
|
16
|
+
PeriodType,
|
|
17
|
+
ApiError
|
|
18
|
+
} from "./types.js";
|
|
4
19
|
import { Agent } from "node:https";
|
|
5
20
|
import { readFileSync } from "fs";
|
|
6
21
|
import { fileURLToPath } from 'url';
|
|
@@ -26,7 +41,7 @@ export class ObsidianClient {
|
|
|
26
41
|
|
|
27
42
|
constructor(config: ObsidianConfig) {
|
|
28
43
|
if (!config.apiKey) {
|
|
29
|
-
throw new ObsidianError("API key is required",
|
|
44
|
+
throw new ObsidianError("API key is required", 40100); // 40100 = Unauthorized
|
|
30
45
|
}
|
|
31
46
|
|
|
32
47
|
// Combine defaults with provided config
|
|
@@ -34,7 +49,7 @@ export class ObsidianClient {
|
|
|
34
49
|
...DEFAULT_OBSIDIAN_CONFIG,
|
|
35
50
|
verifySSL: config.verifySSL ?? process.env.NODE_ENV === 'production', // Enable SSL verification in production by default
|
|
36
51
|
apiKey: config.apiKey,
|
|
37
|
-
timeout: config.timeout ?? 5000,
|
|
52
|
+
timeout: config.timeout ?? 5000, // 5 second default timeout
|
|
38
53
|
maxContentLength: config.maxContentLength ?? 50 * 1024 * 1024, // 50MB
|
|
39
54
|
maxBodyLength: config.maxBodyLength ?? 50 * 1024 * 1024 // 50MB
|
|
40
55
|
};
|
|
@@ -106,12 +121,39 @@ export class ObsidianClient {
|
|
|
106
121
|
// Prevent path traversal attacks
|
|
107
122
|
const normalizedPath = filepath.replace(/\\/g, '/');
|
|
108
123
|
if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
|
|
109
|
-
throw new ObsidianError('Invalid file path: Path traversal not allowed',
|
|
124
|
+
throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001); // 40001 = Path traversal error
|
|
110
125
|
}
|
|
111
126
|
|
|
112
127
|
// Additional path validations
|
|
113
128
|
if (normalizedPath.startsWith('/') || /^[a-zA-Z]:/.test(normalizedPath)) {
|
|
114
|
-
throw new ObsidianError('Invalid file path: Absolute paths not allowed',
|
|
129
|
+
throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002); // 40002 = Invalid path format
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private getErrorCode(status: number): number {
|
|
134
|
+
// Convert HTTP status codes to 5-digit error codes
|
|
135
|
+
switch (status) {
|
|
136
|
+
// Client errors (400-499)
|
|
137
|
+
case 400: return 40000; // Bad request
|
|
138
|
+
case 401: return 40100; // Unauthorized
|
|
139
|
+
case 403: return 40300; // Forbidden
|
|
140
|
+
case 404: return 40400; // Not found
|
|
141
|
+
case 405: return 40500; // Method not allowed
|
|
142
|
+
case 409: return 40900; // Conflict
|
|
143
|
+
case 429: return 42900; // Too many requests
|
|
144
|
+
|
|
145
|
+
// Server errors (500-599)
|
|
146
|
+
case 500: return 50000; // Internal server error
|
|
147
|
+
case 501: return 50100; // Not implemented
|
|
148
|
+
case 502: return 50200; // Bad gateway
|
|
149
|
+
case 503: return 50300; // Service unavailable
|
|
150
|
+
case 504: return 50400; // Gateway timeout
|
|
151
|
+
|
|
152
|
+
// Default cases
|
|
153
|
+
default:
|
|
154
|
+
if (status >= 400 && status < 500) return 40000 + (status - 400) * 100;
|
|
155
|
+
if (status >= 500 && status < 600) return 50000 + (status - 500) * 100;
|
|
156
|
+
return 50000; // Default to internal server error
|
|
115
157
|
}
|
|
116
158
|
}
|
|
117
159
|
|
|
@@ -120,14 +162,28 @@ export class ObsidianClient {
|
|
|
120
162
|
return await operation();
|
|
121
163
|
} catch (error) {
|
|
122
164
|
if (axios.isAxiosError(error)) {
|
|
123
|
-
const axiosError = error as AxiosError<
|
|
165
|
+
const axiosError = error as AxiosError<ApiError>;
|
|
124
166
|
const response = axiosError.response;
|
|
125
167
|
const errorData = response?.data;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
+
|
|
178
|
+
throw new ObsidianError(message, errorCode, errorData);
|
|
129
179
|
}
|
|
130
|
-
|
|
180
|
+
|
|
181
|
+
// For non-Axios errors, use a generic server error code
|
|
182
|
+
if (error instanceof Error) {
|
|
183
|
+
throw new ObsidianError(error.message, 50000, error);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
throw new ObsidianError("Unknown error occurred", 50000, error);
|
|
131
187
|
}
|
|
132
188
|
}
|
|
133
189
|
|
|
@@ -160,16 +216,17 @@ export class ObsidianClient {
|
|
|
160
216
|
});
|
|
161
217
|
}
|
|
162
218
|
|
|
163
|
-
async search(query: string, contextLength: number = 100): Promise<
|
|
219
|
+
async search(query: string, contextLength: number = 100): Promise<SimpleSearchResult[]> {
|
|
164
220
|
return this.safeRequest(async () => {
|
|
165
221
|
const requestId = crypto.randomUUID();
|
|
166
222
|
console.debug(`[${requestId}] Performing simple search: ${query}`);
|
|
167
|
-
const response = await this.client.post<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
223
|
+
const response = await this.client.post<SimpleSearchResult[]>(
|
|
224
|
+
"/search/simple/",
|
|
225
|
+
null,
|
|
226
|
+
{
|
|
227
|
+
params: { query, contextLength }
|
|
171
228
|
}
|
|
172
|
-
|
|
229
|
+
);
|
|
173
230
|
return response.data;
|
|
174
231
|
});
|
|
175
232
|
}
|
|
@@ -177,7 +234,7 @@ export class ObsidianClient {
|
|
|
177
234
|
async appendContent(filepath: string, content: string): Promise<void> {
|
|
178
235
|
this.validateFilePath(filepath);
|
|
179
236
|
if (!content || typeof content !== 'string') {
|
|
180
|
-
throw new ObsidianError('Invalid content: Content must be a non-empty string',
|
|
237
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
|
|
181
238
|
}
|
|
182
239
|
return this.safeRequest(async () => {
|
|
183
240
|
const requestId = crypto.randomUUID();
|
|
@@ -197,7 +254,7 @@ export class ObsidianClient {
|
|
|
197
254
|
async updateContent(filepath: string, content: string): Promise<void> {
|
|
198
255
|
this.validateFilePath(filepath);
|
|
199
256
|
if (!content || typeof content !== 'string') {
|
|
200
|
-
throw new ObsidianError('Invalid content: Content must be a non-empty string',
|
|
257
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
|
|
201
258
|
}
|
|
202
259
|
|
|
203
260
|
return this.safeRequest(async () => {
|
|
@@ -215,21 +272,187 @@ export class ObsidianClient {
|
|
|
215
272
|
});
|
|
216
273
|
}
|
|
217
274
|
|
|
218
|
-
async searchJson(query: JsonLogicQuery): Promise<
|
|
275
|
+
async searchJson(query: JsonLogicQuery): Promise<SearchResponse[]> {
|
|
219
276
|
return this.safeRequest(async () => {
|
|
220
277
|
const requestId = crypto.randomUUID();
|
|
221
278
|
console.debug(`[${requestId}] Performing complex search with query:`, JSON.stringify(query));
|
|
222
|
-
|
|
279
|
+
|
|
280
|
+
// Check if this is a tag-based search
|
|
281
|
+
const isTagSearch = JSON.stringify(query).includes('"contains"') &&
|
|
282
|
+
JSON.stringify(query).includes('"#"');
|
|
283
|
+
|
|
284
|
+
const response = await this.client.post(
|
|
223
285
|
"/search/",
|
|
224
286
|
query,
|
|
225
287
|
{
|
|
226
288
|
headers: {
|
|
227
289
|
"Content-Type": "application/vnd.olrapi.jsonlogic+json",
|
|
228
|
-
"Accept": "application/json"
|
|
290
|
+
"Accept": "application/vnd.olrapi.note+json"
|
|
229
291
|
}
|
|
230
292
|
}
|
|
231
293
|
);
|
|
294
|
+
|
|
295
|
+
if (isTagSearch) {
|
|
296
|
+
return response.data as SimpleSearchResult[];
|
|
297
|
+
}
|
|
298
|
+
return response.data as SearchResult[];
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async getStatus(): Promise<ObsidianStatus> {
|
|
303
|
+
return this.safeRequest(async () => {
|
|
304
|
+
const requestId = crypto.randomUUID();
|
|
305
|
+
console.debug(`[${requestId}] Getting server status`);
|
|
306
|
+
const response = await this.client.get<ObsidianStatus>("/");
|
|
232
307
|
return response.data;
|
|
233
308
|
});
|
|
234
309
|
}
|
|
310
|
+
|
|
311
|
+
async listCommands(): Promise<ObsidianCommand[]> {
|
|
312
|
+
return this.safeRequest(async () => {
|
|
313
|
+
const requestId = crypto.randomUUID();
|
|
314
|
+
console.debug(`[${requestId}] Listing available commands`);
|
|
315
|
+
const response = await this.client.get<{commands: ObsidianCommand[]}>("/commands/");
|
|
316
|
+
return response.data.commands;
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async executeCommand(commandId: string): Promise<void> {
|
|
321
|
+
return this.safeRequest(async () => {
|
|
322
|
+
const requestId = crypto.randomUUID();
|
|
323
|
+
console.debug(`[${requestId}] Executing command: ${commandId}`);
|
|
324
|
+
await this.client.post(`/commands/${commandId}/`);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async openFile(filepath: string, newLeaf: boolean = false): Promise<void> {
|
|
329
|
+
this.validateFilePath(filepath);
|
|
330
|
+
return this.safeRequest(async () => {
|
|
331
|
+
const requestId = crypto.randomUUID();
|
|
332
|
+
console.debug(`[${requestId}] Opening file: ${filepath}`);
|
|
333
|
+
await this.client.post(`/open/${filepath}`, null, {
|
|
334
|
+
params: { newLeaf }
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async getActiveFile(): Promise<NoteJson> {
|
|
340
|
+
return this.safeRequest(async () => {
|
|
341
|
+
const requestId = crypto.randomUUID();
|
|
342
|
+
console.debug(`[${requestId}] Getting active file`);
|
|
343
|
+
const response = await this.client.get<NoteJson>("/active/", {
|
|
344
|
+
headers: {
|
|
345
|
+
"Accept": "application/vnd.olrapi.note+json"
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
return response.data;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async updateActiveFile(content: string): Promise<void> {
|
|
353
|
+
return this.safeRequest(async () => {
|
|
354
|
+
const requestId = crypto.randomUUID();
|
|
355
|
+
console.debug(`[${requestId}] Updating active file`);
|
|
356
|
+
await this.client.put("/active/", content, {
|
|
357
|
+
headers: {
|
|
358
|
+
"Content-Type": "text/markdown"
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async deleteActiveFile(): Promise<void> {
|
|
365
|
+
return this.safeRequest(async () => {
|
|
366
|
+
const requestId = crypto.randomUUID();
|
|
367
|
+
console.debug(`[${requestId}] Deleting active file`);
|
|
368
|
+
await this.client.delete("/active/");
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
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> {
|
|
377
|
+
return this.safeRequest(async () => {
|
|
378
|
+
const requestId = crypto.randomUUID();
|
|
379
|
+
console.debug(`[${requestId}] Patching active file: ${operation} ${targetType} ${target}`);
|
|
380
|
+
|
|
381
|
+
const headers: Record<string, string> = {
|
|
382
|
+
"Operation": operation,
|
|
383
|
+
"Target-Type": targetType,
|
|
384
|
+
"Target": target,
|
|
385
|
+
"Content-Type": options?.contentType || "text/markdown"
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
if (options?.delimiter) {
|
|
389
|
+
headers["Target-Delimiter"] = options.delimiter;
|
|
390
|
+
}
|
|
391
|
+
if (options?.trimWhitespace !== undefined) {
|
|
392
|
+
headers["Trim-Target-Whitespace"] = options.trimWhitespace.toString();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
await this.client.patch("/active/", content, { headers });
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async getPeriodicNote(period: PeriodType["type"]): Promise<NoteJson> {
|
|
400
|
+
return this.safeRequest(async () => {
|
|
401
|
+
const requestId = crypto.randomUUID();
|
|
402
|
+
console.debug(`[${requestId}] Getting ${period} note`);
|
|
403
|
+
const response = await this.client.get<NoteJson>(`/periodic/${period}/`, {
|
|
404
|
+
headers: {
|
|
405
|
+
"Accept": "application/vnd.olrapi.note+json"
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
return response.data;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async updatePeriodicNote(period: PeriodType["type"], content: string): Promise<void> {
|
|
413
|
+
return this.safeRequest(async () => {
|
|
414
|
+
const requestId = crypto.randomUUID();
|
|
415
|
+
console.debug(`[${requestId}] Updating ${period} note`);
|
|
416
|
+
await this.client.put(`/periodic/${period}/`, content, {
|
|
417
|
+
headers: {
|
|
418
|
+
"Content-Type": "text/markdown"
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async deletePeriodicNote(period: PeriodType["type"]): Promise<void> {
|
|
425
|
+
return this.safeRequest(async () => {
|
|
426
|
+
const requestId = crypto.randomUUID();
|
|
427
|
+
console.debug(`[${requestId}] Deleting ${period} note`);
|
|
428
|
+
await this.client.delete(`/periodic/${period}/`);
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
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> {
|
|
437
|
+
return this.safeRequest(async () => {
|
|
438
|
+
const requestId = crypto.randomUUID();
|
|
439
|
+
console.debug(`[${requestId}] Patching ${period} note: ${operation} ${targetType} ${target}`);
|
|
440
|
+
|
|
441
|
+
const headers: Record<string, string> = {
|
|
442
|
+
"Operation": operation,
|
|
443
|
+
"Target-Type": targetType,
|
|
444
|
+
"Target": target,
|
|
445
|
+
"Content-Type": options?.contentType || "text/markdown"
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
if (options?.delimiter) {
|
|
449
|
+
headers["Target-Delimiter"] = options.delimiter;
|
|
450
|
+
}
|
|
451
|
+
if (options?.trimWhitespace !== undefined) {
|
|
452
|
+
headers["Trim-Target-Whitespace"] = options.trimWhitespace.toString();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
await this.client.patch(`/periodic/${period}/`, content, { headers });
|
|
456
|
+
});
|
|
457
|
+
}
|
|
235
458
|
}
|
package/src/properties.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { parse, stringify } from 'yaml';
|
|
2
2
|
import { ObsidianClient } from './obsidian.js';
|
|
3
|
-
import {
|
|
4
|
-
ObsidianProperties,
|
|
5
|
-
ObsidianPropertiesSchema,
|
|
3
|
+
import {
|
|
4
|
+
ObsidianProperties,
|
|
5
|
+
ObsidianPropertiesSchema,
|
|
6
|
+
PropertyUpdateSchema,
|
|
6
7
|
PropertyManagerResult,
|
|
7
|
-
ValidationResult
|
|
8
|
+
ValidationResult
|
|
8
9
|
} from './propertyTypes.js';
|
|
9
10
|
|
|
10
11
|
export class PropertyManager {
|
|
@@ -62,7 +63,7 @@ export class PropertyManager {
|
|
|
62
63
|
* Validate property values
|
|
63
64
|
*/
|
|
64
65
|
validateProperties(properties: Partial<ObsidianProperties>): ValidationResult {
|
|
65
|
-
const result =
|
|
66
|
+
const result = PropertyUpdateSchema.safeParse(properties);
|
|
66
67
|
|
|
67
68
|
if (result.success) {
|
|
68
69
|
return { valid: true, errors: [] };
|
|
@@ -70,7 +71,7 @@ export class PropertyManager {
|
|
|
70
71
|
|
|
71
72
|
return {
|
|
72
73
|
valid: false,
|
|
73
|
-
errors: result.error.errors.map(err =>
|
|
74
|
+
errors: result.error.errors.map(err =>
|
|
74
75
|
`${err.path.join('.')}: ${err.message}`
|
|
75
76
|
)
|
|
76
77
|
};
|
|
@@ -81,20 +82,22 @@ export class PropertyManager {
|
|
|
81
82
|
*/
|
|
82
83
|
mergeProperties(
|
|
83
84
|
existing: ObsidianProperties,
|
|
84
|
-
updates: Partial<ObsidianProperties
|
|
85
|
+
updates: Partial<ObsidianProperties>,
|
|
86
|
+
replace: boolean = false
|
|
85
87
|
): ObsidianProperties {
|
|
86
88
|
const merged = { ...existing };
|
|
87
89
|
|
|
88
90
|
for (const [key, value] of Object.entries(updates)) {
|
|
89
|
-
|
|
91
|
+
// Skip undefined values and timestamp fields
|
|
92
|
+
if (value === undefined || key === 'created' || key === 'modified') continue;
|
|
90
93
|
|
|
91
94
|
const currentValue = merged[key as keyof ObsidianProperties];
|
|
92
95
|
|
|
93
|
-
//
|
|
96
|
+
// Handle arrays based on replace flag
|
|
94
97
|
if (Array.isArray(value) && Array.isArray(currentValue)) {
|
|
95
|
-
merged[key as keyof ObsidianProperties] =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
merged[key as keyof ObsidianProperties] = replace ?
|
|
99
|
+
value :
|
|
100
|
+
[...new Set([...currentValue, ...value])] as any;
|
|
98
101
|
}
|
|
99
102
|
// Special handling for custom object - deep merge
|
|
100
103
|
else if (key === 'custom' && typeof value === 'object' && value !== null) {
|
|
@@ -109,7 +112,7 @@ export class PropertyManager {
|
|
|
109
112
|
}
|
|
110
113
|
}
|
|
111
114
|
|
|
112
|
-
// Always update modified date
|
|
115
|
+
// Always update modified date (this is the only place we set it)
|
|
113
116
|
merged.modified = new Date().toISOString();
|
|
114
117
|
|
|
115
118
|
return merged;
|
|
@@ -142,7 +145,8 @@ export class PropertyManager {
|
|
|
142
145
|
*/
|
|
143
146
|
async updateProperties(
|
|
144
147
|
filepath: string,
|
|
145
|
-
newProperties: Partial<ObsidianProperties
|
|
148
|
+
newProperties: Partial<ObsidianProperties>,
|
|
149
|
+
replace: boolean = false
|
|
146
150
|
): Promise<PropertyManagerResult> {
|
|
147
151
|
try {
|
|
148
152
|
// Validate new properties
|
|
@@ -160,7 +164,7 @@ export class PropertyManager {
|
|
|
160
164
|
const existingProperties = this.parseProperties(content);
|
|
161
165
|
|
|
162
166
|
// Merge properties
|
|
163
|
-
const mergedProperties = this.mergeProperties(existingProperties, newProperties);
|
|
167
|
+
const mergedProperties = this.mergeProperties(existingProperties, newProperties, replace);
|
|
164
168
|
|
|
165
169
|
// Generate new frontmatter
|
|
166
170
|
const newFrontmatter = this.generateProperties(mergedProperties);
|
package/src/propertyTools.ts
CHANGED
|
@@ -16,6 +16,7 @@ interface GetPropertiesArgs {
|
|
|
16
16
|
interface UpdatePropertiesArgs {
|
|
17
17
|
filepath: string;
|
|
18
18
|
properties: Partial<ObsidianProperties>;
|
|
19
|
+
replace?: boolean;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export class GetPropertiesToolHandler extends BaseToolHandler<GetPropertiesArgs> {
|
|
@@ -79,7 +80,7 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
|
|
|
79
80
|
getToolDescription(): Tool {
|
|
80
81
|
return {
|
|
81
82
|
name: this.name,
|
|
82
|
-
description: "Update properties in an Obsidian note's YAML frontmatter. Intelligently merges arrays (tags, type, status), handles custom fields, and automatically
|
|
83
|
+
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)",
|
|
83
84
|
examples: [
|
|
84
85
|
{
|
|
85
86
|
description: "Update basic metadata",
|
|
@@ -93,14 +94,14 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
|
|
|
93
94
|
}
|
|
94
95
|
},
|
|
95
96
|
{
|
|
96
|
-
description: "Update tags and status",
|
|
97
|
+
description: "Update tags and status with replace",
|
|
97
98
|
args: {
|
|
98
99
|
filepath: "docs/feature.md",
|
|
99
100
|
properties: {
|
|
100
101
|
tags: ["#feature", "#in-development", "#high-priority"],
|
|
101
|
-
status: ["in-progress"]
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
status: ["in-progress"]
|
|
103
|
+
},
|
|
104
|
+
replace: true
|
|
104
105
|
}
|
|
105
106
|
},
|
|
106
107
|
{
|
|
@@ -130,25 +131,10 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
|
|
|
130
131
|
description: "Properties to update",
|
|
131
132
|
properties: {
|
|
132
133
|
title: { type: "string" },
|
|
133
|
-
created: { type: "string", format: "date-time" },
|
|
134
|
-
modified: { type: "string", format: "date-time" },
|
|
135
134
|
author: { type: "string" },
|
|
136
135
|
type: {
|
|
137
136
|
type: "array",
|
|
138
|
-
items: {
|
|
139
|
-
type: "string",
|
|
140
|
-
enum: [
|
|
141
|
-
"concept",
|
|
142
|
-
"architecture",
|
|
143
|
-
"specification",
|
|
144
|
-
"protocol",
|
|
145
|
-
"api",
|
|
146
|
-
"research",
|
|
147
|
-
"implementation",
|
|
148
|
-
"guide",
|
|
149
|
-
"reference"
|
|
150
|
-
]
|
|
151
|
-
}
|
|
137
|
+
items: { type: "string" }
|
|
152
138
|
},
|
|
153
139
|
tags: {
|
|
154
140
|
type: "array",
|
|
@@ -186,6 +172,10 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
|
|
|
186
172
|
}
|
|
187
173
|
},
|
|
188
174
|
additionalProperties: false
|
|
175
|
+
},
|
|
176
|
+
replace: {
|
|
177
|
+
type: "boolean",
|
|
178
|
+
description: "If true, arrays will be replaced instead of merged"
|
|
189
179
|
}
|
|
190
180
|
},
|
|
191
181
|
required: ["filepath", "properties"]
|
|
@@ -197,7 +187,8 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
|
|
|
197
187
|
try {
|
|
198
188
|
const result = await this.propertyManager.updateProperties(
|
|
199
189
|
args.filepath,
|
|
200
|
-
args.properties
|
|
190
|
+
args.properties,
|
|
191
|
+
args.replace
|
|
201
192
|
);
|
|
202
193
|
return this.createResponse(result);
|
|
203
194
|
} catch (error) {
|
package/src/propertyTypes.ts
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
3
|
// Define validation schemas
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
"architecture",
|
|
7
|
-
"specification",
|
|
8
|
-
"protocol",
|
|
9
|
-
"api",
|
|
10
|
-
"research",
|
|
11
|
-
"implementation",
|
|
12
|
-
"guide",
|
|
13
|
-
"reference"
|
|
14
|
-
]);
|
|
4
|
+
// Allow any string for type to be more flexible
|
|
5
|
+
export const PropertyType = z.string();
|
|
15
6
|
|
|
16
7
|
export const StatusEnum = z.enum([
|
|
17
8
|
"draft",
|
|
@@ -20,15 +11,44 @@ export const StatusEnum = z.enum([
|
|
|
20
11
|
"complete"
|
|
21
12
|
]);
|
|
22
13
|
|
|
14
|
+
// Schema for reading properties (includes timestamps)
|
|
23
15
|
export const ObsidianPropertiesSchema = z.object({
|
|
16
|
+
// Basic Metadata
|
|
17
|
+
// Note: Timestamps are managed automatically
|
|
18
|
+
title: z.string().optional(),
|
|
19
|
+
modified: z.string().datetime().optional(), // Read-only, managed by MCP server
|
|
20
|
+
author: z.string().optional(),
|
|
21
|
+
|
|
22
|
+
// Classification
|
|
23
|
+
type: z.array(PropertyType).optional(),
|
|
24
|
+
|
|
25
|
+
// Organization
|
|
26
|
+
tags: z.array(z.string().startsWith("#")).optional(),
|
|
27
|
+
|
|
28
|
+
// Technical Metadata
|
|
29
|
+
status: z.array(StatusEnum).optional(),
|
|
30
|
+
version: z.string().optional(),
|
|
31
|
+
platform: z.string().optional(),
|
|
32
|
+
repository: z.string().url().optional(),
|
|
33
|
+
dependencies: z.array(z.string()).optional(),
|
|
34
|
+
|
|
35
|
+
// References
|
|
36
|
+
sources: z.array(z.string()).optional(),
|
|
37
|
+
urls: z.array(z.string().url()).optional(),
|
|
38
|
+
papers: z.array(z.string()).optional(),
|
|
39
|
+
|
|
40
|
+
// Custom Fields
|
|
41
|
+
custom: z.record(z.unknown()).optional()
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Schema for validating property updates (excludes timestamps)
|
|
45
|
+
export const PropertyUpdateSchema = z.object({
|
|
24
46
|
// Basic Metadata
|
|
25
47
|
title: z.string().optional(),
|
|
26
|
-
created: z.string().datetime().optional(),
|
|
27
|
-
modified: z.string().datetime().optional(),
|
|
28
48
|
author: z.string().optional(),
|
|
29
49
|
|
|
30
50
|
// Classification
|
|
31
|
-
type: z.array(
|
|
51
|
+
type: z.array(PropertyType).optional(),
|
|
32
52
|
|
|
33
53
|
// Organization
|
|
34
54
|
tags: z.array(z.string().startsWith("#")).optional(),
|
|
@@ -50,11 +70,13 @@ export const ObsidianPropertiesSchema = z.object({
|
|
|
50
70
|
});
|
|
51
71
|
|
|
52
72
|
export type ObsidianProperties = z.infer<typeof ObsidianPropertiesSchema>;
|
|
73
|
+
export type PropertyUpdate = z.infer<typeof PropertyUpdateSchema>;
|
|
53
74
|
|
|
54
75
|
export interface PropertyOperation {
|
|
55
76
|
operation: 'get' | 'update' | 'patch';
|
|
56
77
|
filepath: string;
|
|
57
78
|
properties?: Partial<ObsidianProperties>;
|
|
79
|
+
replace?: boolean; // Add replace flag
|
|
58
80
|
}
|
|
59
81
|
|
|
60
82
|
export interface ValidationResult {
|