obsidian-mcp-server 1.2.3 → 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 +75 -5
- package/build/obsidian.js +185 -16
- package/build/properties.js +7 -7
- package/build/propertyTools.js +11 -21
- package/build/propertyTypes.js +4 -13
- 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 +9 -7
- package/src/propertyTools.ts +13 -21
- package/src/propertyTypes.ts +5 -13
- 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
|
@@ -82,7 +82,8 @@ export class PropertyManager {
|
|
|
82
82
|
*/
|
|
83
83
|
mergeProperties(
|
|
84
84
|
existing: ObsidianProperties,
|
|
85
|
-
updates: Partial<ObsidianProperties
|
|
85
|
+
updates: Partial<ObsidianProperties>,
|
|
86
|
+
replace: boolean = false
|
|
86
87
|
): ObsidianProperties {
|
|
87
88
|
const merged = { ...existing };
|
|
88
89
|
|
|
@@ -92,11 +93,11 @@ export class PropertyManager {
|
|
|
92
93
|
|
|
93
94
|
const currentValue = merged[key as keyof ObsidianProperties];
|
|
94
95
|
|
|
95
|
-
//
|
|
96
|
+
// Handle arrays based on replace flag
|
|
96
97
|
if (Array.isArray(value) && Array.isArray(currentValue)) {
|
|
97
|
-
merged[key as keyof ObsidianProperties] =
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
merged[key as keyof ObsidianProperties] = replace ?
|
|
99
|
+
value :
|
|
100
|
+
[...new Set([...currentValue, ...value])] as any;
|
|
100
101
|
}
|
|
101
102
|
// Special handling for custom object - deep merge
|
|
102
103
|
else if (key === 'custom' && typeof value === 'object' && value !== null) {
|
|
@@ -144,7 +145,8 @@ export class PropertyManager {
|
|
|
144
145
|
*/
|
|
145
146
|
async updateProperties(
|
|
146
147
|
filepath: string,
|
|
147
|
-
newProperties: Partial<ObsidianProperties
|
|
148
|
+
newProperties: Partial<ObsidianProperties>,
|
|
149
|
+
replace: boolean = false
|
|
148
150
|
): Promise<PropertyManagerResult> {
|
|
149
151
|
try {
|
|
150
152
|
// Validate new properties
|
|
@@ -162,7 +164,7 @@ export class PropertyManager {
|
|
|
162
164
|
const existingProperties = this.parseProperties(content);
|
|
163
165
|
|
|
164
166
|
// Merge properties
|
|
165
|
-
const mergedProperties = this.mergeProperties(existingProperties, newProperties);
|
|
167
|
+
const mergedProperties = this.mergeProperties(existingProperties, newProperties, replace);
|
|
166
168
|
|
|
167
169
|
// Generate new frontmatter
|
|
168
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 manages timestamps
|
|
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
|
{
|
|
@@ -131,23 +132,9 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
|
|
|
131
132
|
properties: {
|
|
132
133
|
title: { type: "string" },
|
|
133
134
|
author: { type: "string" },
|
|
134
|
-
// Note: created and modified timestamps are managed automatically
|
|
135
135
|
type: {
|
|
136
136
|
type: "array",
|
|
137
|
-
items: {
|
|
138
|
-
type: "string",
|
|
139
|
-
enum: [
|
|
140
|
-
"concept",
|
|
141
|
-
"architecture",
|
|
142
|
-
"specification",
|
|
143
|
-
"protocol",
|
|
144
|
-
"api",
|
|
145
|
-
"research",
|
|
146
|
-
"implementation",
|
|
147
|
-
"guide",
|
|
148
|
-
"reference"
|
|
149
|
-
]
|
|
150
|
-
}
|
|
137
|
+
items: { type: "string" }
|
|
151
138
|
},
|
|
152
139
|
tags: {
|
|
153
140
|
type: "array",
|
|
@@ -185,6 +172,10 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
|
|
|
185
172
|
}
|
|
186
173
|
},
|
|
187
174
|
additionalProperties: false
|
|
175
|
+
},
|
|
176
|
+
replace: {
|
|
177
|
+
type: "boolean",
|
|
178
|
+
description: "If true, arrays will be replaced instead of merged"
|
|
188
179
|
}
|
|
189
180
|
},
|
|
190
181
|
required: ["filepath", "properties"]
|
|
@@ -196,7 +187,8 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
|
|
|
196
187
|
try {
|
|
197
188
|
const result = await this.propertyManager.updateProperties(
|
|
198
189
|
args.filepath,
|
|
199
|
-
args.properties
|
|
190
|
+
args.properties,
|
|
191
|
+
args.replace
|
|
200
192
|
);
|
|
201
193
|
return this.createResponse(result);
|
|
202
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",
|
|
@@ -29,7 +20,7 @@ export const ObsidianPropertiesSchema = z.object({
|
|
|
29
20
|
author: z.string().optional(),
|
|
30
21
|
|
|
31
22
|
// Classification
|
|
32
|
-
type: z.array(
|
|
23
|
+
type: z.array(PropertyType).optional(),
|
|
33
24
|
|
|
34
25
|
// Organization
|
|
35
26
|
tags: z.array(z.string().startsWith("#")).optional(),
|
|
@@ -57,7 +48,7 @@ export const PropertyUpdateSchema = z.object({
|
|
|
57
48
|
author: z.string().optional(),
|
|
58
49
|
|
|
59
50
|
// Classification
|
|
60
|
-
type: z.array(
|
|
51
|
+
type: z.array(PropertyType).optional(),
|
|
61
52
|
|
|
62
53
|
// Organization
|
|
63
54
|
tags: z.array(z.string().startsWith("#")).optional(),
|
|
@@ -85,6 +76,7 @@ export interface PropertyOperation {
|
|
|
85
76
|
operation: 'get' | 'update' | 'patch';
|
|
86
77
|
filepath: string;
|
|
87
78
|
properties?: Partial<ObsidianProperties>;
|
|
79
|
+
replace?: boolean; // Add replace flag
|
|
88
80
|
}
|
|
89
81
|
|
|
90
82
|
export interface ValidationResult {
|
package/src/resources.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Resource, TextContent } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import { ObsidianClient } from "./obsidian.js";
|
|
3
|
+
import { TagResponse, SearchMatch, SimpleSearchResult, SearchResponse } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export class TagResource {
|
|
6
|
+
private static readonly TAG_PATTERN = /#[a-zA-Z0-9_-]+/g;
|
|
7
|
+
|
|
8
|
+
constructor(private client: ObsidianClient) {}
|
|
9
|
+
|
|
10
|
+
getResourceDescription(): Resource {
|
|
11
|
+
return {
|
|
12
|
+
uri: "obsidian://tags",
|
|
13
|
+
name: "Obsidian Tags",
|
|
14
|
+
description: "List of all tags used across the Obsidian vault with their usage counts",
|
|
15
|
+
mimeType: "application/json"
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async getContent(): Promise<TextContent[]> {
|
|
20
|
+
try {
|
|
21
|
+
// Search for files containing #
|
|
22
|
+
const query = {
|
|
23
|
+
"contains": [{ "var": "content" }, "#"]
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const results = await this.client.searchJson(query);
|
|
27
|
+
|
|
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
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Convert to sorted response format
|
|
52
|
+
const response: TagResponse = {
|
|
53
|
+
tags: Array.from(tagMap.entries())
|
|
54
|
+
.map(([name, files]) => ({
|
|
55
|
+
name,
|
|
56
|
+
count: files.size,
|
|
57
|
+
files: Array.from(files).sort()
|
|
58
|
+
}))
|
|
59
|
+
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
|
|
60
|
+
metadata: {
|
|
61
|
+
totalOccurrences,
|
|
62
|
+
uniqueTags: tagMap.size,
|
|
63
|
+
scannedFiles
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return [{
|
|
68
|
+
type: "text",
|
|
69
|
+
text: JSON.stringify(response, null, 2)
|
|
70
|
+
}];
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error("Failed to fetch tags:", error);
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|