obsidian-mcp-server 1.1.0
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/.github/workflows/publish.yml +35 -0
- package/README.md +171 -0
- package/build/index.js +11 -0
- package/build/obsidian.js +197 -0
- package/build/server.js +211 -0
- package/build/tools.js +381 -0
- package/build/types.js +20 -0
- package/package.json +55 -0
- package/src/index.ts +12 -0
- package/src/obsidian.ts +235 -0
- package/src/server.ts +277 -0
- package/src/tools.ts +429 -0
- package/src/types.ts +99 -0
- package/tsconfig.json +17 -0
package/build/tools.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { encoding_for_model } from "tiktoken";
|
|
2
|
+
import { ObsidianError } from "./types.js";
|
|
3
|
+
const TOOL_NAMES = {
|
|
4
|
+
LIST_FILES_IN_VAULT: "obsidian_list_files_in_vault",
|
|
5
|
+
LIST_FILES_IN_DIR: "obsidian_list_files_in_dir",
|
|
6
|
+
GET_FILE_CONTENTS: "obsidian_get_file_contents",
|
|
7
|
+
FIND_IN_FILE: "obsidian_find_in_file",
|
|
8
|
+
APPEND_CONTENT: "obsidian_append_content",
|
|
9
|
+
PATCH_CONTENT: "obsidian_patch_content",
|
|
10
|
+
COMPLEX_SEARCH: "obsidian_complex_search"
|
|
11
|
+
};
|
|
12
|
+
// Load token limits from environment or use defaults
|
|
13
|
+
const MAX_TOKENS = parseInt(process.env.MAX_TOKENS ?? '20000');
|
|
14
|
+
const TRUNCATION_MESSAGE = "\n\n[Response truncated due to length]";
|
|
15
|
+
export class BaseToolHandler {
|
|
16
|
+
name;
|
|
17
|
+
client;
|
|
18
|
+
tokenizer = encoding_for_model("gpt-4"); // This is strictly for token counting, not for LLM inference
|
|
19
|
+
isShuttingDown = false;
|
|
20
|
+
constructor(name, client) {
|
|
21
|
+
this.name = name;
|
|
22
|
+
this.client = client;
|
|
23
|
+
// Clean up tokenizer when process exits
|
|
24
|
+
const cleanup = () => {
|
|
25
|
+
if (!this.isShuttingDown) {
|
|
26
|
+
this.isShuttingDown = true;
|
|
27
|
+
if (this.tokenizer) {
|
|
28
|
+
this.tokenizer.free();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
process.on('exit', cleanup);
|
|
33
|
+
process.on('SIGINT', cleanup);
|
|
34
|
+
process.on('SIGTERM', cleanup);
|
|
35
|
+
process.on('uncaughtException', cleanup);
|
|
36
|
+
}
|
|
37
|
+
countTokens(text) {
|
|
38
|
+
return this.tokenizer.encode(text).length;
|
|
39
|
+
}
|
|
40
|
+
truncateToTokenLimit(text) {
|
|
41
|
+
const tokens = this.tokenizer.encode(text);
|
|
42
|
+
if (tokens.length <= MAX_TOKENS) {
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
// Reserve tokens for truncation message
|
|
46
|
+
const messageTokens = this.tokenizer.encode(TRUNCATION_MESSAGE);
|
|
47
|
+
const availableTokens = MAX_TOKENS - messageTokens.length;
|
|
48
|
+
// Decode truncated tokens back to text
|
|
49
|
+
const truncatedText = this.tokenizer.decode(tokens.slice(0, availableTokens));
|
|
50
|
+
return truncatedText + TRUNCATION_MESSAGE;
|
|
51
|
+
}
|
|
52
|
+
createResponse(content) {
|
|
53
|
+
let text;
|
|
54
|
+
// Handle different content types
|
|
55
|
+
if (typeof content === 'string') {
|
|
56
|
+
text = content;
|
|
57
|
+
}
|
|
58
|
+
else if (content instanceof Buffer) {
|
|
59
|
+
text = content.toString('utf-8');
|
|
60
|
+
}
|
|
61
|
+
else if (Array.isArray(content) && content.every(item => typeof item === 'string')) {
|
|
62
|
+
text = content.join('\n');
|
|
63
|
+
}
|
|
64
|
+
else if (content instanceof Error) {
|
|
65
|
+
text = `Error: ${content.message}\n${content.stack || ''}`;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
try {
|
|
69
|
+
text = JSON.stringify(content, null, 2);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
text = String(content);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Count tokens and truncate if necessary
|
|
76
|
+
const originalTokenCount = this.countTokens(text);
|
|
77
|
+
const truncatedText = this.truncateToTokenLimit(text);
|
|
78
|
+
const finalTokenCount = this.countTokens(truncatedText);
|
|
79
|
+
if (originalTokenCount > MAX_TOKENS) {
|
|
80
|
+
console.debug(`[${this.name}] Response truncated:`, `original tokens=${originalTokenCount}`, `truncated tokens=${finalTokenCount}`);
|
|
81
|
+
}
|
|
82
|
+
return [{
|
|
83
|
+
type: "text",
|
|
84
|
+
text: truncatedText
|
|
85
|
+
}];
|
|
86
|
+
}
|
|
87
|
+
handleError(error) {
|
|
88
|
+
if (error instanceof ObsidianError) {
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
if (error instanceof Error) {
|
|
92
|
+
throw new ObsidianError(`Tool '${this.name}' execution failed: ${error.message}`, 500, { originalError: error.stack });
|
|
93
|
+
}
|
|
94
|
+
throw new ObsidianError(`Tool '${this.name}' execution failed with unknown error`, 500, { error });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export class ListFilesInVaultToolHandler extends BaseToolHandler {
|
|
98
|
+
constructor(client) {
|
|
99
|
+
super(TOOL_NAMES.LIST_FILES_IN_VAULT, client);
|
|
100
|
+
}
|
|
101
|
+
getToolDescription() {
|
|
102
|
+
return {
|
|
103
|
+
name: this.name,
|
|
104
|
+
description: "Lists all files and directories in the root directory of your Obsidian vault.",
|
|
105
|
+
examples: [
|
|
106
|
+
{
|
|
107
|
+
description: "List all files in vault",
|
|
108
|
+
args: {}
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
inputSchema: {
|
|
112
|
+
type: "object",
|
|
113
|
+
properties: {},
|
|
114
|
+
required: []
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
async runTool() {
|
|
119
|
+
try {
|
|
120
|
+
const files = await this.client.listFilesInVault();
|
|
121
|
+
return this.createResponse(files);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return this.handleError(error);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export class ListFilesInDirToolHandler extends BaseToolHandler {
|
|
129
|
+
constructor(client) {
|
|
130
|
+
super(TOOL_NAMES.LIST_FILES_IN_DIR, client);
|
|
131
|
+
}
|
|
132
|
+
getToolDescription() {
|
|
133
|
+
return {
|
|
134
|
+
name: this.name,
|
|
135
|
+
description: "Lists all files and directories that exist in a specific Obsidian directory.",
|
|
136
|
+
examples: [
|
|
137
|
+
{
|
|
138
|
+
description: "List files in Documents folder",
|
|
139
|
+
args: {
|
|
140
|
+
dirpath: "Documents"
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
],
|
|
144
|
+
inputSchema: {
|
|
145
|
+
type: "object",
|
|
146
|
+
properties: {
|
|
147
|
+
dirpath: {
|
|
148
|
+
type: "string",
|
|
149
|
+
description: "Path to list files from (relative to your vault root). Note that empty directories will not be returned.",
|
|
150
|
+
format: "path"
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
required: ["dirpath"]
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
async runTool(args) {
|
|
158
|
+
try {
|
|
159
|
+
const files = await this.client.listFilesInDir(args.dirpath);
|
|
160
|
+
return this.createResponse(files);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
return this.handleError(error);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export class GetFileContentsToolHandler extends BaseToolHandler {
|
|
168
|
+
constructor(client) {
|
|
169
|
+
super(TOOL_NAMES.GET_FILE_CONTENTS, client);
|
|
170
|
+
}
|
|
171
|
+
getToolDescription() {
|
|
172
|
+
return {
|
|
173
|
+
name: this.name,
|
|
174
|
+
description: "Return the content of a single file in your vault.",
|
|
175
|
+
inputSchema: {
|
|
176
|
+
type: "object",
|
|
177
|
+
properties: {
|
|
178
|
+
filepath: {
|
|
179
|
+
type: "string",
|
|
180
|
+
description: "Path to the relevant file (relative to your vault root).",
|
|
181
|
+
format: "path"
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
required: ["filepath"]
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
async runTool(args) {
|
|
189
|
+
try {
|
|
190
|
+
const content = await this.client.getFileContents(args.filepath);
|
|
191
|
+
return this.createResponse(content);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
return this.handleError(error);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export class FindInFileToolHandler extends BaseToolHandler {
|
|
199
|
+
constructor(client) {
|
|
200
|
+
super(TOOL_NAMES.FIND_IN_FILE, client);
|
|
201
|
+
}
|
|
202
|
+
getToolDescription() {
|
|
203
|
+
return {
|
|
204
|
+
name: this.name,
|
|
205
|
+
description: "Simple search that returns filenames of documents matching a specified text query across all files in the vault.",
|
|
206
|
+
inputSchema: {
|
|
207
|
+
type: "object",
|
|
208
|
+
properties: {
|
|
209
|
+
query: {
|
|
210
|
+
type: "string",
|
|
211
|
+
description: "Text to search for in the vault."
|
|
212
|
+
},
|
|
213
|
+
contextLength: {
|
|
214
|
+
type: "integer",
|
|
215
|
+
description: "How much context to use for matching (default: 10)",
|
|
216
|
+
default: 10
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
required: ["query"]
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
async runTool(args) {
|
|
224
|
+
try {
|
|
225
|
+
const results = await this.client.search(args.query, args.contextLength);
|
|
226
|
+
// Extract only unique filenames from search results
|
|
227
|
+
const filenames = [...new Set(results.map(result => result.filename))].sort();
|
|
228
|
+
return this.createResponse(filenames);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
return this.handleError(error);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export class AppendContentToolHandler extends BaseToolHandler {
|
|
236
|
+
constructor(client) {
|
|
237
|
+
super(TOOL_NAMES.APPEND_CONTENT, client);
|
|
238
|
+
}
|
|
239
|
+
getToolDescription() {
|
|
240
|
+
return {
|
|
241
|
+
name: this.name,
|
|
242
|
+
description: "Append content to a new or existing file in the vault.",
|
|
243
|
+
examples: [
|
|
244
|
+
{
|
|
245
|
+
description: "Append a new task",
|
|
246
|
+
args: {
|
|
247
|
+
filepath: "tasks.md",
|
|
248
|
+
content: "- [ ] New task to complete"
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
description: "Append meeting notes",
|
|
253
|
+
args: {
|
|
254
|
+
filepath: "meetings/2025-01-23.md",
|
|
255
|
+
content: "## Meeting Notes\n\n- Discussed project timeline\n- Assigned tasks"
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
],
|
|
259
|
+
inputSchema: {
|
|
260
|
+
type: "object",
|
|
261
|
+
properties: {
|
|
262
|
+
filepath: {
|
|
263
|
+
type: "string",
|
|
264
|
+
description: "Path to the file (relative to vault root)",
|
|
265
|
+
format: "path"
|
|
266
|
+
},
|
|
267
|
+
content: {
|
|
268
|
+
type: "string",
|
|
269
|
+
description: "Content to append to the file"
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
required: ["filepath", "content"]
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
async runTool(args) {
|
|
277
|
+
try {
|
|
278
|
+
await this.client.appendContent(args.filepath, args.content);
|
|
279
|
+
return this.createResponse({ message: `Successfully appended content to ${args.filepath}` });
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
return this.handleError(error);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
export class PatchContentToolHandler extends BaseToolHandler {
|
|
287
|
+
constructor(client) {
|
|
288
|
+
super(TOOL_NAMES.PATCH_CONTENT, client);
|
|
289
|
+
}
|
|
290
|
+
getToolDescription() {
|
|
291
|
+
return {
|
|
292
|
+
name: this.name,
|
|
293
|
+
description: "Update the entire content of an existing note or create a new one.",
|
|
294
|
+
examples: [
|
|
295
|
+
{
|
|
296
|
+
description: "Update a note's content",
|
|
297
|
+
args: {
|
|
298
|
+
filepath: "project.md",
|
|
299
|
+
content: "# Project Notes\n\nThis will replace the entire content of the note."
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
],
|
|
303
|
+
inputSchema: {
|
|
304
|
+
type: "object",
|
|
305
|
+
properties: {
|
|
306
|
+
filepath: {
|
|
307
|
+
type: "string",
|
|
308
|
+
description: "Path to the file (relative to vault root)",
|
|
309
|
+
format: "path"
|
|
310
|
+
},
|
|
311
|
+
content: {
|
|
312
|
+
type: "string",
|
|
313
|
+
description: "New content for the note (replaces existing content)"
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
required: ["filepath", "content"]
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
async runTool(args) {
|
|
321
|
+
try {
|
|
322
|
+
await this.client.updateContent(args.filepath, args.content);
|
|
323
|
+
return this.createResponse({ message: `Successfully updated content in ${args.filepath}` });
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
return this.handleError(error);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
export class ComplexSearchToolHandler extends BaseToolHandler {
|
|
331
|
+
constructor(client) {
|
|
332
|
+
super(TOOL_NAMES.COMPLEX_SEARCH, client);
|
|
333
|
+
}
|
|
334
|
+
getToolDescription() {
|
|
335
|
+
return {
|
|
336
|
+
name: this.name,
|
|
337
|
+
description: "Complex search for documents using a JsonLogic query.",
|
|
338
|
+
examples: [
|
|
339
|
+
{
|
|
340
|
+
description: "Find all markdown files",
|
|
341
|
+
args: {
|
|
342
|
+
query: {
|
|
343
|
+
"glob": ["*.md", { "var": "path" }]
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
description: "Find files modified in last week",
|
|
349
|
+
args: {
|
|
350
|
+
query: {
|
|
351
|
+
">=": [
|
|
352
|
+
{ "var": "mtime" },
|
|
353
|
+
{ "date": "-7 days" }
|
|
354
|
+
]
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
],
|
|
359
|
+
inputSchema: {
|
|
360
|
+
type: "object",
|
|
361
|
+
properties: {
|
|
362
|
+
query: {
|
|
363
|
+
type: "object",
|
|
364
|
+
description: "JsonLogic query object. Example: {\"glob\": [\"*.md\", {\"var\": \"path\"}]} matches all markdown files"
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
required: ["query"]
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
async runTool(args) {
|
|
372
|
+
try {
|
|
373
|
+
const results = await this.client.searchJson(args.query);
|
|
374
|
+
return this.createResponse(results);
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
return this.handleError(error);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
//# sourceMappingURL=tools.js.map
|
package/build/types.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const DEFAULT_OBSIDIAN_CONFIG = {
|
|
2
|
+
protocol: "https",
|
|
3
|
+
host: "127.0.0.1",
|
|
4
|
+
port: 27124
|
|
5
|
+
};
|
|
6
|
+
export const DEFAULT_RATE_LIMIT_CONFIG = {
|
|
7
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
8
|
+
maxRequests: 200
|
|
9
|
+
};
|
|
10
|
+
export class ObsidianError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
details;
|
|
13
|
+
constructor(message, code, details) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.details = details;
|
|
17
|
+
this.name = "ObsidianError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=types.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "obsidian-mcp-server",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Model Context Protocol server for Obsidian integration with token-aware response handling",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18.0.0"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc && chmod +x build/index.js",
|
|
12
|
+
"start": "node build/index.js",
|
|
13
|
+
"dev": "tsc -w",
|
|
14
|
+
"clean": "rm -rf build",
|
|
15
|
+
"rebuild": "npm run clean && npm run build",
|
|
16
|
+
"test": "echo \"No tests specified yet\" && exit 0",
|
|
17
|
+
"lint": "eslint . --ext .ts",
|
|
18
|
+
"format": "prettier --write \"src/**/*.ts\""
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.4.0",
|
|
22
|
+
"axios": "^1.7.9",
|
|
23
|
+
"dotenv": "^16.4.7",
|
|
24
|
+
"tiktoken": "^1.0.18"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^22.10.10",
|
|
28
|
+
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
|
29
|
+
"@typescript-eslint/parser": "^8.21.0",
|
|
30
|
+
"eslint": "^9.18.0",
|
|
31
|
+
"eslint-config-prettier": "^10.0.1",
|
|
32
|
+
"eslint-plugin-prettier": "^5.2.3",
|
|
33
|
+
"prettier": "^3.4.2",
|
|
34
|
+
"typescript": "^5.7.3"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"mcp",
|
|
38
|
+
"obsidian",
|
|
39
|
+
"llm",
|
|
40
|
+
"llm-agent",
|
|
41
|
+
"ai",
|
|
42
|
+
"claude",
|
|
43
|
+
"model-context-protocol",
|
|
44
|
+
"tiktoken"
|
|
45
|
+
],
|
|
46
|
+
"author": "cyanheads",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+ssh://git@github.com/cyanheads/obsidian-mcp-server.git"
|
|
50
|
+
},
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/cyanheads/obsidian-mcp-server/issues"
|
|
53
|
+
},
|
|
54
|
+
"homepage": "https://github.com/cyanheads/obsidian-mcp-server#readme"
|
|
55
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from "./server.js";
|
|
3
|
+
|
|
4
|
+
// Main entry point
|
|
5
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
6
|
+
run().catch((error) => {
|
|
7
|
+
console.error("Failed to start server:", error);
|
|
8
|
+
process.exit(1);
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export { run };
|
package/src/obsidian.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import type { AxiosInstance, AxiosError, AxiosRequestConfig } from "axios";
|
|
3
|
+
import { ObsidianConfig, ObsidianError, ObsidianFile, SearchResult, DEFAULT_OBSIDIAN_CONFIG, ObsidianServerConfig, JsonLogicQuery } from "./types.js";
|
|
4
|
+
import { Agent } from "node:https";
|
|
5
|
+
import { readFileSync } from "fs";
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { dirname, join } from "path";
|
|
8
|
+
|
|
9
|
+
// Get package version for user agent
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
const packagePath = join(__dirname, '..', '..', 'package.json');
|
|
13
|
+
const VERSION = (() => {
|
|
14
|
+
try {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'));
|
|
16
|
+
return pkg.version;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.warn('Could not read package.json version:', error);
|
|
19
|
+
return '1.1.0'; // Fallback version
|
|
20
|
+
}
|
|
21
|
+
})();
|
|
22
|
+
|
|
23
|
+
export class ObsidianClient {
|
|
24
|
+
private client: AxiosInstance;
|
|
25
|
+
private config: Required<ObsidianConfig> & ObsidianServerConfig;
|
|
26
|
+
|
|
27
|
+
constructor(config: ObsidianConfig) {
|
|
28
|
+
if (!config.apiKey) {
|
|
29
|
+
throw new ObsidianError("API key is required", 401);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Combine defaults with provided config
|
|
33
|
+
this.config = {
|
|
34
|
+
...DEFAULT_OBSIDIAN_CONFIG,
|
|
35
|
+
verifySSL: config.verifySSL ?? process.env.NODE_ENV === 'production', // Enable SSL verification in production by default
|
|
36
|
+
apiKey: config.apiKey,
|
|
37
|
+
timeout: config.timeout ?? 5000,
|
|
38
|
+
maxContentLength: config.maxContentLength ?? 50 * 1024 * 1024, // 50MB
|
|
39
|
+
maxBodyLength: config.maxBodyLength ?? 50 * 1024 * 1024 // 50MB
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Configure HTTPS agent
|
|
43
|
+
const httpsAgent = new Agent({
|
|
44
|
+
rejectUnauthorized: this.config.verifySSL
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const axiosConfig: AxiosRequestConfig = {
|
|
48
|
+
baseURL: this.getBaseUrl(),
|
|
49
|
+
headers: {
|
|
50
|
+
...this.getHeaders(),
|
|
51
|
+
// Security headers
|
|
52
|
+
'X-Content-Type-Options': 'nosniff',
|
|
53
|
+
'X-Frame-Options': 'DENY',
|
|
54
|
+
'X-XSS-Protection': '1; mode=block',
|
|
55
|
+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
|
|
56
|
+
},
|
|
57
|
+
validateStatus: (status) => status >= 200 && status < 300,
|
|
58
|
+
timeout: this.config.timeout,
|
|
59
|
+
maxRedirects: 5,
|
|
60
|
+
maxContentLength: this.config.maxContentLength,
|
|
61
|
+
maxBodyLength: this.config.maxBodyLength,
|
|
62
|
+
httpsAgent,
|
|
63
|
+
// Additional security configurations
|
|
64
|
+
xsrfCookieName: 'XSRF-TOKEN',
|
|
65
|
+
xsrfHeaderName: 'X-XSRF-TOKEN',
|
|
66
|
+
withCredentials: true,
|
|
67
|
+
decompress: true
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (!this.config.verifySSL) {
|
|
71
|
+
console.warn(
|
|
72
|
+
"WARNING: SSL verification is disabled. This is not recommended for production use.",
|
|
73
|
+
process.env.NODE_ENV === 'production' ? "Consider enabling SSL verification." : "This is acceptable for local development only."
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.client = axios.create(axiosConfig);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private getBaseUrl(): string {
|
|
81
|
+
return `${this.config.protocol}://${this.config.host}:${this.config.port}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private getHeaders(): Record<string, string> {
|
|
85
|
+
const headers: Record<string, string> = {
|
|
86
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
87
|
+
'Accept': 'application/json',
|
|
88
|
+
'User-Agent': `obsidian-mcp-server/${VERSION}`
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Sanitize headers
|
|
92
|
+
return Object.fromEntries(
|
|
93
|
+
Object.entries(headers).map(([key, value]) => [
|
|
94
|
+
key,
|
|
95
|
+
this.sanitizeHeader(value)
|
|
96
|
+
])
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private sanitizeHeader(value: string): string {
|
|
101
|
+
// Remove any potentially harmful characters from header values
|
|
102
|
+
return value.replace(/[^\w\s\-\._~:/?#\[\]@!$&'()*+,;=]/g, '');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private validateFilePath(filepath: string): void {
|
|
106
|
+
// Prevent path traversal attacks
|
|
107
|
+
const normalizedPath = filepath.replace(/\\/g, '/');
|
|
108
|
+
if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
|
|
109
|
+
throw new ObsidianError('Invalid file path: Path traversal not allowed', 400);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Additional path validations
|
|
113
|
+
if (normalizedPath.startsWith('/') || /^[a-zA-Z]:/.test(normalizedPath)) {
|
|
114
|
+
throw new ObsidianError('Invalid file path: Absolute paths not allowed', 400);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async safeRequest<T>(operation: () => Promise<T>): Promise<T> {
|
|
119
|
+
try {
|
|
120
|
+
return await operation();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (axios.isAxiosError(error)) {
|
|
123
|
+
const axiosError = error as AxiosError<{ errorCode?: number; message?: string }>;
|
|
124
|
+
const response = axiosError.response;
|
|
125
|
+
const errorData = response?.data;
|
|
126
|
+
const code = errorData?.errorCode ?? response?.status ?? 500;
|
|
127
|
+
const message = errorData?.message ?? axiosError.message ?? "Unknown error";
|
|
128
|
+
throw new ObsidianError(message, code, errorData);
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async listFilesInVault(): Promise<ObsidianFile[]> {
|
|
135
|
+
return this.safeRequest(async () => {
|
|
136
|
+
const requestId = crypto.randomUUID();
|
|
137
|
+
console.debug(`[${requestId}] Listing vault files`);
|
|
138
|
+
const response = await this.client.get<{ files: ObsidianFile[] }>("/vault/");
|
|
139
|
+
return response.data.files;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async listFilesInDir(dirpath: string): Promise<ObsidianFile[]> {
|
|
144
|
+
this.validateFilePath(dirpath);
|
|
145
|
+
return this.safeRequest(async () => {
|
|
146
|
+
const requestId = crypto.randomUUID();
|
|
147
|
+
console.debug(`[${requestId}] Listing files in directory: ${dirpath}`);
|
|
148
|
+
const response = await this.client.get<{ files: ObsidianFile[] }>(`/vault/${dirpath}/`);
|
|
149
|
+
return response.data.files;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async getFileContents(filepath: string): Promise<string> {
|
|
154
|
+
this.validateFilePath(filepath);
|
|
155
|
+
return this.safeRequest(async () => {
|
|
156
|
+
const requestId = crypto.randomUUID();
|
|
157
|
+
console.debug(`[${requestId}] Getting file contents: ${filepath}`);
|
|
158
|
+
const response = await this.client.get<string>(`/vault/${filepath}`);
|
|
159
|
+
return response.data;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async search(query: string, contextLength: number = 100): Promise<SearchResult[]> {
|
|
164
|
+
return this.safeRequest(async () => {
|
|
165
|
+
const requestId = crypto.randomUUID();
|
|
166
|
+
console.debug(`[${requestId}] Performing simple search: ${query}`);
|
|
167
|
+
const response = await this.client.post<SearchResult[]>("/search/simple/", undefined, {
|
|
168
|
+
params: {
|
|
169
|
+
query,
|
|
170
|
+
contextLength
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
return response.data;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async appendContent(filepath: string, content: string): Promise<void> {
|
|
178
|
+
this.validateFilePath(filepath);
|
|
179
|
+
if (!content || typeof content !== 'string') {
|
|
180
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 400);
|
|
181
|
+
}
|
|
182
|
+
return this.safeRequest(async () => {
|
|
183
|
+
const requestId = crypto.randomUUID();
|
|
184
|
+
console.debug(`[${requestId}] Appending content to: ${filepath}`);
|
|
185
|
+
await this.client.post(
|
|
186
|
+
`/vault/${filepath}`,
|
|
187
|
+
content,
|
|
188
|
+
{
|
|
189
|
+
headers: {
|
|
190
|
+
"Content-Type": "text/markdown"
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async updateContent(filepath: string, content: string): Promise<void> {
|
|
198
|
+
this.validateFilePath(filepath);
|
|
199
|
+
if (!content || typeof content !== 'string') {
|
|
200
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 400);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return this.safeRequest(async () => {
|
|
204
|
+
const requestId = crypto.randomUUID();
|
|
205
|
+
console.debug(`[${requestId}] Updating content in: ${filepath}`);
|
|
206
|
+
await this.client.put(
|
|
207
|
+
`/vault/${filepath}`,
|
|
208
|
+
content,
|
|
209
|
+
{
|
|
210
|
+
headers: {
|
|
211
|
+
"Content-Type": "text/markdown"
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async searchJson(query: JsonLogicQuery): Promise<SearchResult[]> {
|
|
219
|
+
return this.safeRequest(async () => {
|
|
220
|
+
const requestId = crypto.randomUUID();
|
|
221
|
+
console.debug(`[${requestId}] Performing complex search with query:`, JSON.stringify(query));
|
|
222
|
+
const response = await this.client.post<SearchResult[]>(
|
|
223
|
+
"/search/",
|
|
224
|
+
query,
|
|
225
|
+
{
|
|
226
|
+
headers: {
|
|
227
|
+
"Content-Type": "application/vnd.olrapi.jsonlogic+json",
|
|
228
|
+
"Accept": "application/json"
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
return response.data;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|