mcp-obsidian-cli 1.1.0 → 1.2.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.
Files changed (3) hide show
  1. package/lib/helpers.js +62 -0
  2. package/package.json +3 -2
  3. package/server.js +65 -121
package/lib/helpers.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Pure helper functions extracted from server.js for testability.
3
+ */
4
+
5
+ import { readFileSync, existsSync } from "node:fs";
6
+ import { load as yamlLoad } from "js-yaml";
7
+
8
+ /**
9
+ * Load config from YAML file with env var overrides.
10
+ * @param {string} configFile - Path to the YAML config file.
11
+ * @returns {{ vault: string, cliPath: string, timeoutMs: number }}
12
+ */
13
+ export function loadConfig(configFile) {
14
+ const defaults = { vault: "", cliPath: "obsidian", timeoutMs: 15000 };
15
+ let config = { ...defaults };
16
+
17
+ if (existsSync(configFile)) {
18
+ try {
19
+ const content = readFileSync(configFile, "utf8");
20
+ const fileConfig = yamlLoad(content);
21
+ if (fileConfig) {
22
+ if (fileConfig.vault) config.vault = fileConfig.vault;
23
+ if (fileConfig.cliPath) config.cliPath = fileConfig.cliPath;
24
+ if (fileConfig.timeoutMs) config.timeoutMs = fileConfig.timeoutMs;
25
+ }
26
+ } catch (err) {
27
+ console.error("Warning: failed to load config file:", err.message);
28
+ }
29
+ }
30
+
31
+ if (process.env.OBSIDIAN_VAULT) config.vault = process.env.OBSIDIAN_VAULT;
32
+ if (process.env.OBSIDIAN_CLI_PATH) config.cliPath = process.env.OBSIDIAN_CLI_PATH;
33
+ if (process.env.OBSIDIAN_TIMEOUT_MS) config.timeoutMs = parseInt(process.env.OBSIDIAN_TIMEOUT_MS, 10);
34
+
35
+ return config;
36
+ }
37
+
38
+ /**
39
+ * Minimal arg parser: splits on whitespace but respects key="value with spaces".
40
+ */
41
+ export function parseArgs(str) {
42
+ const args = [];
43
+ const re = /(?:[^\s"]+|"[^"]*")+/g;
44
+ let m;
45
+ while ((m = re.exec(str)) !== null) {
46
+ args.push(m[0].replace(/"([^"]*)"/g, "$1"));
47
+ }
48
+ return args;
49
+ }
50
+
51
+ /** Standard MCP text result. */
52
+ export function text(content) {
53
+ return { content: [{ type: "text", text: content }] };
54
+ }
55
+
56
+ /** Standard MCP error result. */
57
+ export function errorResult(content, code = "EXECUTION_ERROR") {
58
+ return {
59
+ content: [{ type: "text", text: content }],
60
+ isError: true,
61
+ };
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-obsidian-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "MCP server wrapping the Obsidian CLI — full native API access over Model Context Protocol",
6
6
  "main": "server.js",
@@ -9,11 +9,12 @@
9
9
  },
10
10
  "files": [
11
11
  "server.js",
12
+ "lib/",
12
13
  "prompts/"
13
14
  ],
14
15
  "scripts": {
15
16
  "start": "node server.js",
16
- "test": "node --test test/run.test.js"
17
+ "test": "node --test test/*.test.js"
17
18
  },
18
19
  "engines": {
19
20
  "node": ">=18"
package/server.js CHANGED
@@ -22,12 +22,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
22
22
  import { z } from "zod";
23
23
  import { execFile } from "node:child_process";
24
24
  import { promisify } from "node:util";
25
- import { readFileSync, mkdirSync, existsSync } from "node:fs";
26
- import { load as yamlLoad } from "js-yaml";
25
+ import { readFileSync, existsSync } from "node:fs";
27
26
  import { homedir } from "node:os";
28
27
  import { join, dirname } from "node:path";
29
28
  import { fileURLToPath } from "node:url";
30
29
  import { exec } from "node:child_process";
30
+ import { loadConfig, parseArgs, text, errorResult } from "./lib/helpers.js";
31
31
 
32
32
  const execFileAsync = promisify(execFile);
33
33
  const execAsync = promisify(exec);
@@ -46,30 +46,6 @@ const configBase = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
46
46
  const CONFIG_DIR = join(configBase, "mcp-obsidian-cli");
47
47
  const CONFIG_FILE = join(CONFIG_DIR, "config.yaml");
48
48
 
49
- function loadConfig() {
50
- const defaults = { vault: "", cliPath: "obsidian", timeoutMs: 15000 };
51
- let config = { ...defaults };
52
-
53
- if (existsSync(CONFIG_FILE)) {
54
- try {
55
- const content = readFileSync(CONFIG_FILE, "utf8");
56
- const fileConfig = yamlLoad(content);
57
- if (fileConfig) {
58
- if (fileConfig.vault) config.vault = fileConfig.vault;
59
- if (fileConfig.cliPath) config.cliPath = fileConfig.cliPath;
60
- if (fileConfig.timeoutMs) config.timeoutMs = fileConfig.timeoutMs;
61
- }
62
- } catch (err) {
63
- console.error("Warning: failed to load config file:", err.message);
64
- }
65
- }
66
-
67
- if (process.env.OBSIDIAN_VAULT) config.vault = process.env.OBSIDIAN_VAULT;
68
- if (process.env.OBSIDIAN_CLI_PATH) config.cliPath = process.env.OBSIDIAN_CLI_PATH;
69
- if (process.env.OBSIDIAN_TIMEOUT_MS) config.timeoutMs = parseInt(process.env.OBSIDIAN_TIMEOUT_MS, 10);
70
-
71
- return config;
72
- }
73
49
 
74
50
  const KNOWN_CLI_PATHS = [
75
51
  "/Applications/Obsidian.app/Contents/MacOS/obsidian",
@@ -100,35 +76,20 @@ async function resolveCliPath(configured) {
100
76
  return configured;
101
77
  }
102
78
 
103
- const config = loadConfig();
79
+ const config = loadConfig(CONFIG_FILE);
104
80
  const CLI = await resolveCliPath(config.cliPath);
105
81
  const VAULT = config.vault;
106
82
  const TIMEOUT_MS = config.timeoutMs;
107
83
 
108
84
  async function checkObsidianRunning() {
109
85
  try {
110
- const { stdout: psOut } = await execAsync("ps aux | grep -i obsidian | grep -v grep | grep -v Helper", { timeout: 2000 });
111
- const obsidianRunning = psOut.includes("/Applications/Obsidian.app");
112
- if (!obsidianRunning) {
113
- return { running: false, version: null };
114
- }
115
- const { stdout } = await execFileAsync(CLI, ["version"], { timeout: 2000 });
116
- const hasStartupMsg = stdout.includes("Loaded updated app package") ||
117
- stdout.includes("Checking for update") ||
118
- stdout.includes("App is up to date") ||
119
- stdout.includes("Latest version is");
120
- if (hasStartupMsg) {
121
- return { running: false, version: null };
122
- }
123
- if (stdout.includes("(installer")) {
124
- const match = stdout.match(/(\d+\.\d+\.\d+)/);
125
- if (match) {
126
- return { running: true, version: match[1] };
127
- }
128
- }
129
- return { running: false, version: null };
130
- } catch (err) {
131
- return { running: false, version: null };
86
+ const { stdout } = await execAsync(
87
+ "ps aux | grep -i obsidian | grep -v grep | grep -v Helper",
88
+ { timeout: 2000 }
89
+ );
90
+ return stdout.includes("/Applications/Obsidian.app");
91
+ } catch {
92
+ return false;
132
93
  }
133
94
  }
134
95
 
@@ -140,8 +101,8 @@ async function checkObsidianRunning() {
140
101
  * Run the Obsidian CLI with the given argument string.
141
102
  * Returns { stdout, stderr } or throws on non-zero exit / timeout.
142
103
  */
143
- async function run(argString) {
144
- const args = parseArgs(argString);
104
+ async function run(input) {
105
+ const args = Array.isArray(input) ? [...input] : parseArgs(input);
145
106
  if (VAULT) args.push(`vault=${VAULT}`);
146
107
 
147
108
  try {
@@ -177,35 +138,10 @@ async function run(argString) {
177
138
  }
178
139
  }
179
140
 
180
- /**
181
- * Minimal arg parser: splits on whitespace but respects key="value with spaces".
182
- */
183
- function parseArgs(str) {
184
- const args = [];
185
- const re = /(?:[^\s"]+|"[^"]*")+/g;
186
- let m;
187
- while ((m = re.exec(str)) !== null) {
188
- args.push(m[0]);
189
- }
190
- return args;
191
- }
192
-
193
- /** Standard MCP text result. */
194
- function text(content) {
195
- return { content: [{ type: "text", text: content }] };
196
- }
197
-
198
- /** Standard MCP error result. */
199
- function errorResult(content, code = "EXECUTION_ERROR") {
200
- return {
201
- content: [{ type: "text", text: content }],
202
- isError: true,
203
- };
204
- }
205
141
 
206
- /** Run CLI, return MCP result. */
207
- async function runTool(argString) {
208
- const { stdout, stderr, error } = await run(argString);
142
+ /** Run CLI, return MCP result. Accepts a command string or an args array. */
143
+ async function runTool(input) {
144
+ const { stdout, stderr, error } = await run(input);
209
145
  if (error) {
210
146
  return errorResult(error.message, error.type);
211
147
  }
@@ -221,7 +157,7 @@ async function runTool(argString) {
221
157
 
222
158
  const server = new McpServer({
223
159
  name: "obsidian-mcp",
224
- version: "1.1.0",
160
+ version: "1.2.0",
225
161
  capabilities: { tools: {} },
226
162
  });
227
163
 
@@ -263,7 +199,7 @@ server.tool(
263
199
  "obsidian_daily_append",
264
200
  "Append content to today's daily note.\n\nParameters:\n content (required) — markdown text to append at the end of today's daily note\n\nExamples:\n obsidian_daily_append({ content: \"- Meeting with team at 3pm\" })\n obsidian_daily_append({ content: \"> [!tip] Remember\\n> Review PR before EOD\" })",
265
201
  { content: z.string().describe("Content to append") },
266
- async ({ content }) => runTool(`daily:append content="${content.replace(/"/g, '\\"')}"`),
202
+ async ({ content }) => runTool(["daily:append", `content=${content}`]),
267
203
  );
268
204
 
269
205
  server.tool(
@@ -275,8 +211,10 @@ server.tool(
275
211
  },
276
212
  async ({ file, path }) => {
277
213
  if (!file && !path) return text("Error: provide file= or path=");
278
- const arg = file ? `file="${file}"` : `path="${path}"`;
279
- return runTool(`read ${arg}`);
214
+ const args = ["read"];
215
+ if (file) args.push(`file=${file}`);
216
+ if (path) args.push(`path=${path}`);
217
+ return runTool(args);
280
218
  },
281
219
  );
282
220
 
@@ -289,10 +227,10 @@ server.tool(
289
227
  limit: z.number().optional().describe("Max files to return"),
290
228
  },
291
229
  async ({ query, path, limit }) => {
292
- let cmd = `search:context query="${query.replace(/"/g, '\\"')}"`;
293
- if (path) cmd += ` path="${path}"`;
294
- if (limit) cmd += ` limit=${limit}`;
295
- return runTool(cmd);
230
+ const args = ["search:context", `query=${query}`];
231
+ if (path) args.push(`path=${path}`);
232
+ if (limit) args.push(`limit=${limit}`);
233
+ return runTool(args);
296
234
  },
297
235
  );
298
236
 
@@ -303,9 +241,9 @@ server.tool(
303
241
  sort: z.enum(["name", "count"]).optional().describe("Sort order"),
304
242
  },
305
243
  async ({ sort }) => {
306
- let cmd = "tags counts";
307
- if (sort) cmd += ` sort=${sort}`;
308
- return runTool(cmd);
244
+ const args = ["tags", "counts"];
245
+ if (sort) args.push(`sort=${sort}`);
246
+ return runTool(args);
309
247
  },
310
248
  );
311
249
 
@@ -319,12 +257,12 @@ server.tool(
319
257
  path: z.string().optional().describe("Filter by file path"),
320
258
  },
321
259
  async ({ daily, todo, done, path }) => {
322
- let cmd = "tasks";
323
- if (daily) cmd += " daily";
324
- if (todo) cmd += " todo";
325
- if (done) cmd += " done";
326
- if (path) cmd += ` path="${path}"`;
327
- return runTool(cmd);
260
+ const args = ["tasks"];
261
+ if (daily) args.push("daily");
262
+ if (todo) args.push("todo");
263
+ if (done) args.push("done");
264
+ if (path) args.push(`path=${path}`);
265
+ return runTool(args);
328
266
  },
329
267
  );
330
268
 
@@ -338,15 +276,16 @@ server.tool(
338
276
  },
339
277
  async ({ file, path, name }) => {
340
278
  if (name && (file || path)) {
341
- // Read a specific property from a specific file
342
- const target = file ? `file="${file}"` : `path="${path}"`;
343
- return runTool(`property:read name="${name}" ${target}`);
279
+ const args = ["property:read", `name=${name}`];
280
+ if (file) args.push(`file=${file}`);
281
+ if (path) args.push(`path=${path}`);
282
+ return runTool(args);
344
283
  }
345
- let cmd = "properties";
346
- if (file) cmd += ` file="${file}"`;
347
- if (path) cmd += ` path="${path}"`;
348
- cmd += " counts";
349
- return runTool(cmd);
284
+ const args = ["properties"];
285
+ if (file) args.push(`file=${file}`);
286
+ if (path) args.push(`path=${path}`);
287
+ args.push("counts");
288
+ return runTool(args);
350
289
  },
351
290
  );
352
291
 
@@ -360,12 +299,12 @@ server.tool(
360
299
  template: z.string().optional().describe("Template to use"),
361
300
  },
362
301
  async ({ name, path, content, template }) => {
363
- let cmd = "create";
364
- if (name) cmd += ` name="${name}"`;
365
- if (path) cmd += ` path="${path}"`;
366
- if (template) cmd += ` template="${template}"`;
367
- if (content) cmd += ` content="${content.replace(/"/g, '\\"')}"`;
368
- return runTool(cmd);
302
+ const args = ["create"];
303
+ if (name) args.push(`name=${name}`);
304
+ if (path) args.push(`path=${path}`);
305
+ if (template) args.push(`template=${template}`);
306
+ if (content) args.push(`content=${content}`);
307
+ return runTool(args);
369
308
  },
370
309
  );
371
310
 
@@ -379,9 +318,11 @@ server.tool(
379
318
  path: z.string().optional().describe("File path"),
380
319
  },
381
320
  async ({ name, value, file, path: filePath }) => {
382
- const target = file ? `file="${file}"` : filePath ? `path="${filePath}"` : "";
383
- if (!target) return text("Error: provide file= or path=");
384
- return runTool(`property:set name="${name}" value="${value.replace(/"/g, '\\"')}" ${target}`);
321
+ if (!file && !filePath) return text("Error: provide file= or path=");
322
+ const args = ["property:set", `name=${name}`, `value=${value}`];
323
+ if (file) args.push(`file=${file}`);
324
+ if (filePath) args.push(`path=${filePath}`);
325
+ return runTool(args);
385
326
  },
386
327
  );
387
328
 
@@ -393,8 +334,11 @@ server.tool(
393
334
  path: z.string().optional().describe("File path"),
394
335
  },
395
336
  async ({ file, path }) => {
396
- const target = file ? `file="${file}"` : path ? `path="${path}"` : "";
397
- return runTool(`backlinks ${target} counts`);
337
+ const args = ["backlinks"];
338
+ if (file) args.push(`file=${file}`);
339
+ if (path) args.push(`path=${path}`);
340
+ args.push("counts");
341
+ return runTool(args);
398
342
  },
399
343
  );
400
344
 
@@ -406,10 +350,10 @@ server.tool(
406
350
  ext: z.string().optional().describe("Filter by extension"),
407
351
  },
408
352
  async ({ folder, ext }) => {
409
- let cmd = "files";
410
- if (folder) cmd += ` folder="${folder}"`;
411
- if (ext) cmd += ` ext=${ext}`;
412
- return runTool(cmd);
353
+ const args = ["files"];
354
+ if (folder) args.push(`folder=${folder}`);
355
+ if (ext) args.push(`ext=${ext}`);
356
+ return runTool(args);
413
357
  },
414
358
  );
415
359
 
@@ -464,13 +408,13 @@ for (const [name, content] of Object.entries(promptContent)) {
464
408
  // ---- Start ---------------------------------------------------------------
465
409
 
466
410
  async function main() {
467
- const { running, version } = await checkObsidianRunning();
411
+ const running = await checkObsidianRunning();
468
412
  if (!running) {
469
413
  console.error("Error: Obsidian is not running. Please open Obsidian and try again.");
470
414
  process.exit(1);
471
415
  }
472
416
  const transport = new StdioServerTransport();
473
- console.error(`obsidian-mcp server running on stdio (Obsidian ${version})`);
417
+ console.error("obsidian-mcp server running on stdio");
474
418
  await server.connect(transport);
475
419
  }
476
420