obsidian-mcp-server 1.2.3 → 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -5
- package/build/obsidian.js +181 -32
- package/build/properties.js +7 -7
- package/build/propertyTools.js +11 -21
- package/build/propertyTypes.js +4 -13
- package/build/resources.js +111 -0
- package/build/server.js +36 -12
- package/build/tools.js +425 -48
- package/build/types.js +21 -4
- package/package.json +3 -3
- package/src/obsidian.ts +260 -38
- package/src/properties.ts +9 -7
- package/src/propertyTools.ts +13 -21
- package/src/propertyTypes.ts +5 -13
- package/src/resources.ts +123 -0
- package/src/server.ts +39 -14
- package/src/tools.ts +461 -49
- package/src/types.ts +89 -4
package/README.md
CHANGED
|
@@ -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,19 @@ export class ObsidianClient {
|
|
|
23
23
|
config;
|
|
24
24
|
constructor(config) {
|
|
25
25
|
if (!config.apiKey) {
|
|
26
|
-
throw new ObsidianError("API key
|
|
26
|
+
throw new ObsidianError("Missing API key. To fix this:\n" +
|
|
27
|
+
"1. Install the 'Local REST API' plugin in Obsidian\n" +
|
|
28
|
+
"2. Enable the plugin in Obsidian Settings\n" +
|
|
29
|
+
"3. Copy your API key from Obsidian Settings > Local REST API\n" +
|
|
30
|
+
"4. Provide the API key in your configuration", 40100 // Unauthorized
|
|
31
|
+
);
|
|
27
32
|
}
|
|
28
33
|
// Combine defaults with provided config
|
|
29
34
|
this.config = {
|
|
30
35
|
...DEFAULT_OBSIDIAN_CONFIG,
|
|
31
|
-
verifySSL: config.verifySSL ??
|
|
36
|
+
verifySSL: config.verifySSL ?? true, // Default to true as required by Obsidian REST API plugin
|
|
32
37
|
apiKey: config.apiKey,
|
|
33
|
-
timeout: config.timeout ?? 5000,
|
|
38
|
+
timeout: config.timeout ?? 5000, // 5 second default timeout
|
|
34
39
|
maxContentLength: config.maxContentLength ?? 50 * 1024 * 1024, // 50MB
|
|
35
40
|
maxBodyLength: config.maxBodyLength ?? 50 * 1024 * 1024 // 50MB
|
|
36
41
|
};
|
|
@@ -61,7 +66,9 @@ export class ObsidianClient {
|
|
|
61
66
|
decompress: true
|
|
62
67
|
};
|
|
63
68
|
if (!this.config.verifySSL) {
|
|
64
|
-
console.warn("WARNING: SSL verification is disabled.
|
|
69
|
+
console.warn("WARNING: SSL verification is disabled. The Obsidian REST API plugin requires HTTPS by default.\n" +
|
|
70
|
+
"Make sure you have configured the certificate as a trusted certificate authority.\n" +
|
|
71
|
+
"See Obsidian Settings > Local REST API > 'How to Access' for setup instructions.");
|
|
65
72
|
}
|
|
66
73
|
this.client = axios.create(axiosConfig);
|
|
67
74
|
}
|
|
@@ -88,11 +95,33 @@ export class ObsidianClient {
|
|
|
88
95
|
// Prevent path traversal attacks
|
|
89
96
|
const normalizedPath = filepath.replace(/\\/g, '/');
|
|
90
97
|
if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
|
|
91
|
-
throw new ObsidianError('Invalid file path: Path traversal not allowed',
|
|
98
|
+
throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001);
|
|
92
99
|
}
|
|
93
100
|
// Additional path validations
|
|
94
101
|
if (normalizedPath.startsWith('/') || /^[a-zA-Z]:/.test(normalizedPath)) {
|
|
95
|
-
throw new ObsidianError('Invalid file path: Absolute paths not allowed',
|
|
102
|
+
throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
getErrorCode(status) {
|
|
106
|
+
switch (status) {
|
|
107
|
+
case 400: return 40000; // Bad request
|
|
108
|
+
case 401: return 40100; // Unauthorized
|
|
109
|
+
case 403: return 40300; // Forbidden
|
|
110
|
+
case 404: return 40400; // Not found
|
|
111
|
+
case 405: return 40500; // Method not allowed
|
|
112
|
+
case 409: return 40900; // Conflict
|
|
113
|
+
case 429: return 42900; // Too many requests
|
|
114
|
+
case 500: return 50000; // Internal server error
|
|
115
|
+
case 501: return 50100; // Not implemented
|
|
116
|
+
case 502: return 50200; // Bad gateway
|
|
117
|
+
case 503: return 50300; // Service unavailable
|
|
118
|
+
case 504: return 50400; // Gateway timeout
|
|
119
|
+
default:
|
|
120
|
+
if (status >= 400 && status < 500)
|
|
121
|
+
return 40000 + (status - 400) * 100;
|
|
122
|
+
if (status >= 500 && status < 600)
|
|
123
|
+
return 50000 + (status - 500) * 100;
|
|
124
|
+
return 50000;
|
|
96
125
|
}
|
|
97
126
|
}
|
|
98
127
|
async safeRequest(operation) {
|
|
@@ -104,17 +133,45 @@ export class ObsidianClient {
|
|
|
104
133
|
const axiosError = error;
|
|
105
134
|
const response = axiosError.response;
|
|
106
135
|
const errorData = response?.data;
|
|
107
|
-
|
|
136
|
+
// Handle common connection errors with helpful messages
|
|
137
|
+
if (error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
|
|
138
|
+
throw new ObsidianError(`SSL certificate verification failed. To fix this:\n` +
|
|
139
|
+
`1. Go to Obsidian Settings > Local REST API\n` +
|
|
140
|
+
`2. Under 'How to Access', copy the certificate\n` +
|
|
141
|
+
`3. Configure the certificate as a trusted certificate authority\n` +
|
|
142
|
+
`4. Ensure you're using HTTPS (HTTP is disabled by default)\n` +
|
|
143
|
+
`Original error: ${error.message}`, 50001, // SSL error code
|
|
144
|
+
{ code: error.code, config: { verifySSL: this.config.verifySSL } });
|
|
145
|
+
}
|
|
146
|
+
if (error.code === 'ECONNREFUSED') {
|
|
147
|
+
throw new ObsidianError(`Connection refused. To fix this:\n` +
|
|
148
|
+
`1. Ensure Obsidian is running\n` +
|
|
149
|
+
`2. Verify the 'Local REST API' plugin is enabled in Obsidian Settings\n` +
|
|
150
|
+
`3. Check that you're using the correct host (${this.config.host}) and port (${this.config.port})\n` +
|
|
151
|
+
`4. Make sure HTTPS is enabled in the plugin settings`, 50002, // Connection refused
|
|
152
|
+
{ code: error.code });
|
|
153
|
+
}
|
|
154
|
+
if (response?.status === 401) {
|
|
155
|
+
throw new ObsidianError(`Authentication failed. To fix this:\n` +
|
|
156
|
+
`1. Go to Obsidian Settings > Local REST API\n` +
|
|
157
|
+
`2. Copy your API key from the settings\n` +
|
|
158
|
+
`3. Update your configuration with the new API key\n` +
|
|
159
|
+
`Note: The API key changes when you regenerate certificates`, 40100, // Unauthorized
|
|
160
|
+
{ code: error.code });
|
|
161
|
+
}
|
|
162
|
+
// For other errors, use API error code if available
|
|
163
|
+
const errorCode = errorData?.errorCode ?? this.getErrorCode(response?.status ?? 500);
|
|
108
164
|
const message = errorData?.message ?? axiosError.message ?? "Unknown error";
|
|
109
|
-
throw new ObsidianError(message,
|
|
165
|
+
throw new ObsidianError(message, errorCode, errorData);
|
|
110
166
|
}
|
|
111
|
-
|
|
167
|
+
if (error instanceof Error) {
|
|
168
|
+
throw new ObsidianError(error.message, 50000, error);
|
|
169
|
+
}
|
|
170
|
+
throw new ObsidianError("Unknown error occurred", 50000, error);
|
|
112
171
|
}
|
|
113
172
|
}
|
|
114
173
|
async listFilesInVault() {
|
|
115
174
|
return this.safeRequest(async () => {
|
|
116
|
-
const requestId = crypto.randomUUID();
|
|
117
|
-
console.debug(`[${requestId}] Listing vault files`);
|
|
118
175
|
const response = await this.client.get("/vault/");
|
|
119
176
|
return response.data.files;
|
|
120
177
|
});
|
|
@@ -122,8 +179,6 @@ export class ObsidianClient {
|
|
|
122
179
|
async listFilesInDir(dirpath) {
|
|
123
180
|
this.validateFilePath(dirpath);
|
|
124
181
|
return this.safeRequest(async () => {
|
|
125
|
-
const requestId = crypto.randomUUID();
|
|
126
|
-
console.debug(`[${requestId}] Listing files in directory: ${dirpath}`);
|
|
127
182
|
const response = await this.client.get(`/vault/${dirpath}/`);
|
|
128
183
|
return response.data.files;
|
|
129
184
|
});
|
|
@@ -131,33 +186,22 @@ export class ObsidianClient {
|
|
|
131
186
|
async getFileContents(filepath) {
|
|
132
187
|
this.validateFilePath(filepath);
|
|
133
188
|
return this.safeRequest(async () => {
|
|
134
|
-
const requestId = crypto.randomUUID();
|
|
135
|
-
console.debug(`[${requestId}] Getting file contents: ${filepath}`);
|
|
136
189
|
const response = await this.client.get(`/vault/${filepath}`);
|
|
137
190
|
return response.data;
|
|
138
191
|
});
|
|
139
192
|
}
|
|
140
193
|
async search(query, contextLength = 100) {
|
|
141
194
|
return this.safeRequest(async () => {
|
|
142
|
-
const
|
|
143
|
-
console.debug(`[${requestId}] Performing simple search: ${query}`);
|
|
144
|
-
const response = await this.client.post("/search/simple/", undefined, {
|
|
145
|
-
params: {
|
|
146
|
-
query,
|
|
147
|
-
contextLength
|
|
148
|
-
}
|
|
149
|
-
});
|
|
195
|
+
const response = await this.client.post("/search/simple/", null, { params: { query, contextLength } });
|
|
150
196
|
return response.data;
|
|
151
197
|
});
|
|
152
198
|
}
|
|
153
199
|
async appendContent(filepath, content) {
|
|
154
200
|
this.validateFilePath(filepath);
|
|
155
201
|
if (!content || typeof content !== 'string') {
|
|
156
|
-
throw new ObsidianError('Invalid content: Content must be a non-empty string',
|
|
202
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
157
203
|
}
|
|
158
204
|
return this.safeRequest(async () => {
|
|
159
|
-
const requestId = crypto.randomUUID();
|
|
160
|
-
console.debug(`[${requestId}] Appending content to: ${filepath}`);
|
|
161
205
|
await this.client.post(`/vault/${filepath}`, content, {
|
|
162
206
|
headers: {
|
|
163
207
|
"Content-Type": "text/markdown"
|
|
@@ -168,11 +212,9 @@ export class ObsidianClient {
|
|
|
168
212
|
async updateContent(filepath, content) {
|
|
169
213
|
this.validateFilePath(filepath);
|
|
170
214
|
if (!content || typeof content !== 'string') {
|
|
171
|
-
throw new ObsidianError('Invalid content: Content must be a non-empty string',
|
|
215
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
172
216
|
}
|
|
173
217
|
return this.safeRequest(async () => {
|
|
174
|
-
const requestId = crypto.randomUUID();
|
|
175
|
-
console.debug(`[${requestId}] Updating content in: ${filepath}`);
|
|
176
218
|
await this.client.put(`/vault/${filepath}`, content, {
|
|
177
219
|
headers: {
|
|
178
220
|
"Content-Type": "text/markdown"
|
|
@@ -182,16 +224,123 @@ export class ObsidianClient {
|
|
|
182
224
|
}
|
|
183
225
|
async searchJson(query) {
|
|
184
226
|
return this.safeRequest(async () => {
|
|
185
|
-
const
|
|
186
|
-
|
|
227
|
+
const isTagSearch = JSON.stringify(query).includes('"contains"') &&
|
|
228
|
+
JSON.stringify(query).includes('"#"');
|
|
187
229
|
const response = await this.client.post("/search/", query, {
|
|
188
230
|
headers: {
|
|
189
231
|
"Content-Type": "application/vnd.olrapi.jsonlogic+json",
|
|
190
|
-
"Accept": "application/json"
|
|
232
|
+
"Accept": "application/vnd.olrapi.note+json"
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
return isTagSearch ? response.data : response.data;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
async getStatus() {
|
|
239
|
+
return this.safeRequest(async () => {
|
|
240
|
+
const response = await this.client.get("/");
|
|
241
|
+
return response.data;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
async listCommands() {
|
|
245
|
+
return this.safeRequest(async () => {
|
|
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
|
+
await this.client.post(`/commands/${commandId}/`);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
async openFile(filepath, newLeaf = false) {
|
|
256
|
+
this.validateFilePath(filepath);
|
|
257
|
+
return this.safeRequest(async () => {
|
|
258
|
+
await this.client.post(`/open/${filepath}`, null, {
|
|
259
|
+
params: { newLeaf }
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
async getActiveFile() {
|
|
264
|
+
return this.safeRequest(async () => {
|
|
265
|
+
const response = await this.client.get("/active/", {
|
|
266
|
+
headers: {
|
|
267
|
+
"Accept": "application/vnd.olrapi.note+json"
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
return response.data;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
async updateActiveFile(content) {
|
|
274
|
+
return this.safeRequest(async () => {
|
|
275
|
+
await this.client.put("/active/", content, {
|
|
276
|
+
headers: {
|
|
277
|
+
"Content-Type": "text/markdown"
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
async deleteActiveFile() {
|
|
283
|
+
return this.safeRequest(async () => {
|
|
284
|
+
await this.client.delete("/active/");
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
async patchActiveFile(operation, targetType, target, content, options) {
|
|
288
|
+
return this.safeRequest(async () => {
|
|
289
|
+
const headers = {
|
|
290
|
+
"Operation": operation,
|
|
291
|
+
"Target-Type": targetType,
|
|
292
|
+
"Target": target,
|
|
293
|
+
"Content-Type": options?.contentType || "text/markdown"
|
|
294
|
+
};
|
|
295
|
+
if (options?.delimiter) {
|
|
296
|
+
headers["Target-Delimiter"] = options.delimiter;
|
|
297
|
+
}
|
|
298
|
+
if (options?.trimWhitespace !== undefined) {
|
|
299
|
+
headers["Trim-Target-Whitespace"] = options.trimWhitespace.toString();
|
|
300
|
+
}
|
|
301
|
+
await this.client.patch("/active/", content, { headers });
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
async getPeriodicNote(period) {
|
|
305
|
+
return this.safeRequest(async () => {
|
|
306
|
+
const response = await this.client.get(`/periodic/${period}/`, {
|
|
307
|
+
headers: {
|
|
308
|
+
"Accept": "application/vnd.olrapi.note+json"
|
|
191
309
|
}
|
|
192
310
|
});
|
|
193
311
|
return response.data;
|
|
194
312
|
});
|
|
195
313
|
}
|
|
314
|
+
async updatePeriodicNote(period, content) {
|
|
315
|
+
return this.safeRequest(async () => {
|
|
316
|
+
await this.client.put(`/periodic/${period}/`, content, {
|
|
317
|
+
headers: {
|
|
318
|
+
"Content-Type": "text/markdown"
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
async deletePeriodicNote(period) {
|
|
324
|
+
return this.safeRequest(async () => {
|
|
325
|
+
await this.client.delete(`/periodic/${period}/`);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
async patchPeriodicNote(period, operation, targetType, target, content, options) {
|
|
329
|
+
return this.safeRequest(async () => {
|
|
330
|
+
const headers = {
|
|
331
|
+
"Operation": operation,
|
|
332
|
+
"Target-Type": targetType,
|
|
333
|
+
"Target": target,
|
|
334
|
+
"Content-Type": options?.contentType || "text/markdown"
|
|
335
|
+
};
|
|
336
|
+
if (options?.delimiter) {
|
|
337
|
+
headers["Target-Delimiter"] = options.delimiter;
|
|
338
|
+
}
|
|
339
|
+
if (options?.trimWhitespace !== undefined) {
|
|
340
|
+
headers["Trim-Target-Whitespace"] = options.trimWhitespace.toString();
|
|
341
|
+
}
|
|
342
|
+
await this.client.patch(`/periodic/${period}/`, content, { headers });
|
|
343
|
+
});
|
|
344
|
+
}
|
|
196
345
|
}
|
|
197
346
|
//# sourceMappingURL=obsidian.js.map
|
package/build/properties.js
CHANGED
|
@@ -63,18 +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
70
|
if (value === undefined || key === 'created' || key === 'modified')
|
|
71
71
|
continue;
|
|
72
72
|
const currentValue = merged[key];
|
|
73
|
-
//
|
|
73
|
+
// Handle arrays based on replace flag
|
|
74
74
|
if (Array.isArray(value) && Array.isArray(currentValue)) {
|
|
75
|
-
merged[key] =
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
merged[key] = replace ?
|
|
76
|
+
value :
|
|
77
|
+
[...new Set([...currentValue, ...value])];
|
|
78
78
|
}
|
|
79
79
|
// Special handling for custom object - deep merge
|
|
80
80
|
else if (key === 'custom' && typeof value === 'object' && value !== null) {
|
|
@@ -116,7 +116,7 @@ export class PropertyManager {
|
|
|
116
116
|
/**
|
|
117
117
|
* Update properties of a note
|
|
118
118
|
*/
|
|
119
|
-
async updateProperties(filepath, newProperties) {
|
|
119
|
+
async updateProperties(filepath, newProperties, replace = false) {
|
|
120
120
|
try {
|
|
121
121
|
// Validate new properties
|
|
122
122
|
const validation = this.validateProperties(newProperties);
|
|
@@ -131,7 +131,7 @@ export class PropertyManager {
|
|
|
131
131
|
const content = await this.client.getFileContents(filepath);
|
|
132
132
|
const existingProperties = this.parseProperties(content);
|
|
133
133
|
// Merge properties
|
|
134
|
-
const mergedProperties = this.mergeProperties(existingProperties, newProperties);
|
|
134
|
+
const mergedProperties = this.mergeProperties(existingProperties, newProperties, replace);
|
|
135
135
|
// Generate new frontmatter
|
|
136
136
|
const newFrontmatter = this.generateProperties(mergedProperties);
|
|
137
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 manages timestamps
|
|
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
|
{
|
|
@@ -112,23 +112,9 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler {
|
|
|
112
112
|
properties: {
|
|
113
113
|
title: { type: "string" },
|
|
114
114
|
author: { type: "string" },
|
|
115
|
-
// Note: created and modified timestamps are managed automatically
|
|
116
115
|
type: {
|
|
117
116
|
type: "array",
|
|
118
|
-
items: {
|
|
119
|
-
type: "string",
|
|
120
|
-
enum: [
|
|
121
|
-
"concept",
|
|
122
|
-
"architecture",
|
|
123
|
-
"specification",
|
|
124
|
-
"protocol",
|
|
125
|
-
"api",
|
|
126
|
-
"research",
|
|
127
|
-
"implementation",
|
|
128
|
-
"guide",
|
|
129
|
-
"reference"
|
|
130
|
-
]
|
|
131
|
-
}
|
|
117
|
+
items: { type: "string" }
|
|
132
118
|
},
|
|
133
119
|
tags: {
|
|
134
120
|
type: "array",
|
|
@@ -166,6 +152,10 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler {
|
|
|
166
152
|
}
|
|
167
153
|
},
|
|
168
154
|
additionalProperties: false
|
|
155
|
+
},
|
|
156
|
+
replace: {
|
|
157
|
+
type: "boolean",
|
|
158
|
+
description: "If true, arrays will be replaced instead of merged"
|
|
169
159
|
}
|
|
170
160
|
},
|
|
171
161
|
required: ["filepath", "properties"]
|
|
@@ -174,7 +164,7 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler {
|
|
|
174
164
|
}
|
|
175
165
|
async runTool(args) {
|
|
176
166
|
try {
|
|
177
|
-
const result = await this.propertyManager.updateProperties(args.filepath, args.properties);
|
|
167
|
+
const result = await this.propertyManager.updateProperties(args.filepath, args.properties, args.replace);
|
|
178
168
|
return this.createResponse(result);
|
|
179
169
|
}
|
|
180
170
|
catch (error) {
|
package/build/propertyTypes.js
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
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",
|
|
@@ -25,7 +16,7 @@ export const ObsidianPropertiesSchema = z.object({
|
|
|
25
16
|
modified: z.string().datetime().optional(), // Read-only, managed by MCP server
|
|
26
17
|
author: z.string().optional(),
|
|
27
18
|
// Classification
|
|
28
|
-
type: z.array(
|
|
19
|
+
type: z.array(PropertyType).optional(),
|
|
29
20
|
// Organization
|
|
30
21
|
tags: z.array(z.string().startsWith("#")).optional(),
|
|
31
22
|
// Technical Metadata
|
|
@@ -47,7 +38,7 @@ export const PropertyUpdateSchema = z.object({
|
|
|
47
38
|
title: z.string().optional(),
|
|
48
39
|
author: z.string().optional(),
|
|
49
40
|
// Classification
|
|
50
|
-
type: z.array(
|
|
41
|
+
type: z.array(PropertyType).optional(),
|
|
51
42
|
// Organization
|
|
52
43
|
tags: z.array(z.string().startsWith("#")).optional(),
|
|
53
44
|
// Technical Metadata
|