mcp-obsidian-cli 1.0.1 → 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.
package/server.js CHANGED
@@ -22,43 +22,30 @@ 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
- import { join } from "node:path";
27
+ import { join, dirname } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
29
  import { exec } from "node:child_process";
30
+ import { loadConfig, parseArgs, text, errorResult } from "./lib/helpers.js";
30
31
 
31
32
  const execFileAsync = promisify(execFile);
32
33
  const execAsync = promisify(exec);
33
34
 
35
+ const __dirname = dirname(fileURLToPath(import.meta.url));
36
+ const PROMPTS_DIR = join(__dirname, "prompts");
37
+
38
+ const promptContent = {
39
+ "obsidian-cli": readFileSync(join(PROMPTS_DIR, "obsidian-cli.md"), "utf8"),
40
+ "obsidian-markdown": readFileSync(join(PROMPTS_DIR, "obsidian-markdown.md"), "utf8"),
41
+ "obsidian-bases": readFileSync(join(PROMPTS_DIR, "obsidian-bases.md"), "utf8"),
42
+ "obsidian-canvas": readFileSync(join(PROMPTS_DIR, "obsidian-canvas.md"), "utf8"),
43
+ };
44
+
34
45
  const configBase = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
35
46
  const CONFIG_DIR = join(configBase, "mcp-obsidian-cli");
36
47
  const CONFIG_FILE = join(CONFIG_DIR, "config.yaml");
37
48
 
38
- function loadConfig() {
39
- const defaults = { vault: "", cliPath: "obsidian", timeoutMs: 15000 };
40
- let config = { ...defaults };
41
-
42
- if (existsSync(CONFIG_FILE)) {
43
- try {
44
- const content = readFileSync(CONFIG_FILE, "utf8");
45
- const fileConfig = yamlLoad(content);
46
- if (fileConfig) {
47
- if (fileConfig.vault) config.vault = fileConfig.vault;
48
- if (fileConfig.cliPath) config.cliPath = fileConfig.cliPath;
49
- if (fileConfig.timeoutMs) config.timeoutMs = fileConfig.timeoutMs;
50
- }
51
- } catch (err) {
52
- console.error("Warning: failed to load config file:", err.message);
53
- }
54
- }
55
-
56
- if (process.env.OBSIDIAN_VAULT) config.vault = process.env.OBSIDIAN_VAULT;
57
- if (process.env.OBSIDIAN_CLI_PATH) config.cliPath = process.env.OBSIDIAN_CLI_PATH;
58
- if (process.env.OBSIDIAN_TIMEOUT_MS) config.timeoutMs = parseInt(process.env.OBSIDIAN_TIMEOUT_MS, 10);
59
-
60
- return config;
61
- }
62
49
 
63
50
  const KNOWN_CLI_PATHS = [
64
51
  "/Applications/Obsidian.app/Contents/MacOS/obsidian",
@@ -89,35 +76,20 @@ async function resolveCliPath(configured) {
89
76
  return configured;
90
77
  }
91
78
 
92
- const config = loadConfig();
79
+ const config = loadConfig(CONFIG_FILE);
93
80
  const CLI = await resolveCliPath(config.cliPath);
94
81
  const VAULT = config.vault;
95
82
  const TIMEOUT_MS = config.timeoutMs;
96
83
 
97
84
  async function checkObsidianRunning() {
98
85
  try {
99
- const { stdout: psOut } = await execAsync("ps aux | grep -i obsidian | grep -v grep | grep -v Helper", { timeout: 2000 });
100
- const obsidianRunning = psOut.includes("/Applications/Obsidian.app");
101
- if (!obsidianRunning) {
102
- return { running: false, version: null };
103
- }
104
- const { stdout } = await execFileAsync(CLI, ["version"], { timeout: 2000 });
105
- const hasStartupMsg = stdout.includes("Loaded updated app package") ||
106
- stdout.includes("Checking for update") ||
107
- stdout.includes("App is up to date") ||
108
- stdout.includes("Latest version is");
109
- if (hasStartupMsg) {
110
- return { running: false, version: null };
111
- }
112
- if (stdout.includes("(installer")) {
113
- const match = stdout.match(/(\d+\.\d+\.\d+)/);
114
- if (match) {
115
- return { running: true, version: match[1] };
116
- }
117
- }
118
- return { running: false, version: null };
119
- } catch (err) {
120
- 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;
121
93
  }
122
94
  }
123
95
 
@@ -129,8 +101,8 @@ async function checkObsidianRunning() {
129
101
  * Run the Obsidian CLI with the given argument string.
130
102
  * Returns { stdout, stderr } or throws on non-zero exit / timeout.
131
103
  */
132
- async function run(argString) {
133
- const args = parseArgs(argString);
104
+ async function run(input) {
105
+ const args = Array.isArray(input) ? [...input] : parseArgs(input);
134
106
  if (VAULT) args.push(`vault=${VAULT}`);
135
107
 
136
108
  try {
@@ -166,35 +138,10 @@ async function run(argString) {
166
138
  }
167
139
  }
168
140
 
169
- /**
170
- * Minimal arg parser: splits on whitespace but respects key="value with spaces".
171
- */
172
- function parseArgs(str) {
173
- const args = [];
174
- const re = /(?:[^\s"]+|"[^"]*")+/g;
175
- let m;
176
- while ((m = re.exec(str)) !== null) {
177
- args.push(m[0]);
178
- }
179
- return args;
180
- }
181
-
182
- /** Standard MCP text result. */
183
- function text(content) {
184
- return { content: [{ type: "text", text: content }] };
185
- }
186
141
 
187
- /** Standard MCP error result. */
188
- function errorResult(content, code = "EXECUTION_ERROR") {
189
- return {
190
- content: [{ type: "text", text: content }],
191
- isError: true,
192
- };
193
- }
194
-
195
- /** Run CLI, return MCP result. */
196
- async function runTool(argString) {
197
- 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);
198
145
  if (error) {
199
146
  return errorResult(error.message, error.type);
200
147
  }
@@ -210,7 +157,7 @@ async function runTool(argString) {
210
157
 
211
158
  const server = new McpServer({
212
159
  name: "obsidian-mcp",
213
- version: "1.0.0",
160
+ version: "1.2.0",
214
161
  capabilities: { tools: {} },
215
162
  });
216
163
 
@@ -236,71 +183,73 @@ Examples:
236
183
 
237
184
  server.tool(
238
185
  "obsidian_daily_read",
239
- "Read today's daily note contents.",
186
+ "Read today's daily note contents.\n\nReturns the full markdown content of today's daily note. Returns an error if no daily note exists for today.",
240
187
  {},
241
188
  async () => runTool("daily:read"),
242
189
  );
243
190
 
244
191
  server.tool(
245
192
  "obsidian_daily_path",
246
- "Get the file path of today's daily note.",
193
+ "Get the file path of today's daily note.\n\nReturns the vault-relative path (e.g. 'Daily/2026-04-03.md'). Useful for constructing paths for other tools.",
247
194
  {},
248
195
  async () => runTool("daily:path"),
249
196
  );
250
197
 
251
198
  server.tool(
252
199
  "obsidian_daily_append",
253
- "Append content to today's daily note.",
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\" })",
254
201
  { content: z.string().describe("Content to append") },
255
- async ({ content }) => runTool(`daily:append content="${content.replace(/"/g, '\\"')}"`),
202
+ async ({ content }) => runTool(["daily:append", `content=${content}`]),
256
203
  );
257
204
 
258
205
  server.tool(
259
206
  "obsidian_read",
260
- "Read a note by file name (wikilink-style) or exact path.",
207
+ "Read a note by file name (wikilink-style) or exact path.\n\nParameters:\n file (optional) — note name using wikilink resolution (e.g. 'My Note')\n path (optional) — exact vault-relative path (e.g. 'folder/My Note.md')\n One of file or path is required.\n\nExamples:\n obsidian_read({ file: \"Meeting Notes\" })\n obsidian_read({ path: \"Projects/todo.md\" })",
261
208
  {
262
209
  file: z.string().optional().describe("File name (wikilink resolution)"),
263
210
  path: z.string().optional().describe("Exact file path"),
264
211
  },
265
212
  async ({ file, path }) => {
266
213
  if (!file && !path) return text("Error: provide file= or path=");
267
- const arg = file ? `file="${file}"` : `path="${path}"`;
268
- 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);
269
218
  },
270
219
  );
271
220
 
272
221
  server.tool(
273
222
  "obsidian_search",
274
- "Full-text search across the vault with line context.",
223
+ "Full-text search across the vault with line context.\n\nParameters:\n query (required) — search terms, supports Obsidian query syntax\n path (optional) — restrict results to a folder path\n limit (optional) — max number of files to return\n\nExamples:\n obsidian_search({ query: \"meeting notes\" })\n obsidian_search({ query: \"project status\", path: \"Work/\", limit: 5 })",
275
224
  {
276
225
  query: z.string().describe("Search query"),
277
226
  path: z.string().optional().describe("Limit to folder"),
278
227
  limit: z.number().optional().describe("Max files to return"),
279
228
  },
280
229
  async ({ query, path, limit }) => {
281
- let cmd = `search:context query="${query.replace(/"/g, '\\"')}"`;
282
- if (path) cmd += ` path="${path}"`;
283
- if (limit) cmd += ` limit=${limit}`;
284
- 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);
285
234
  },
286
235
  );
287
236
 
288
237
  server.tool(
289
238
  "obsidian_tags",
290
- "List tags in the vault with counts.",
239
+ "List tags in the vault with counts.\n\nParameters:\n sort (optional) — 'name' or 'count' (default: name)\n\nExamples:\n obsidian_tags({})\n obsidian_tags({ sort: \"count\" })",
291
240
  {
292
241
  sort: z.enum(["name", "count"]).optional().describe("Sort order"),
293
242
  },
294
243
  async ({ sort }) => {
295
- let cmd = "tags counts";
296
- if (sort) cmd += ` sort=${sort}`;
297
- return runTool(cmd);
244
+ const args = ["tags", "counts"];
245
+ if (sort) args.push(`sort=${sort}`);
246
+ return runTool(args);
298
247
  },
299
248
  );
300
249
 
301
250
  server.tool(
302
251
  "obsidian_tasks",
303
- "List tasks. Use daily=true for today's tasks only.",
252
+ "List tasks from vault notes.\n\nParameters:\n daily (optional) — true to show only today's daily note tasks\n todo (optional) — true to show only incomplete tasks\n done (optional) — true to show only completed tasks\n path (optional) — filter by file path\n\nExamples:\n obsidian_tasks({ daily: true })\n obsidian_tasks({ todo: true, path: \"Projects/\" })",
304
253
  {
305
254
  daily: z.boolean().optional().describe("Show only daily note tasks"),
306
255
  todo: z.boolean().optional().describe("Show incomplete tasks only"),
@@ -308,18 +257,18 @@ server.tool(
308
257
  path: z.string().optional().describe("Filter by file path"),
309
258
  },
310
259
  async ({ daily, todo, done, path }) => {
311
- let cmd = "tasks";
312
- if (daily) cmd += " daily";
313
- if (todo) cmd += " todo";
314
- if (done) cmd += " done";
315
- if (path) cmd += ` path="${path}"`;
316
- 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);
317
266
  },
318
267
  );
319
268
 
320
269
  server.tool(
321
270
  "obsidian_properties",
322
- "List or read frontmatter properties.",
271
+ "List or read frontmatter properties.\n\nParameters:\n file (optional) — note name for wikilink resolution\n path (optional) — exact file path\n name (optional) — specific property name to read (requires file or path)\n\nExamples:\n obsidian_properties({}) — list all properties with counts\n obsidian_properties({ file: \"My Note\" }) — properties of a specific note\n obsidian_properties({ file: \"My Note\", name: \"status\" }) — read one property",
323
272
  {
324
273
  file: z.string().optional().describe("File name"),
325
274
  path: z.string().optional().describe("File path"),
@@ -327,21 +276,22 @@ server.tool(
327
276
  },
328
277
  async ({ file, path, name }) => {
329
278
  if (name && (file || path)) {
330
- // Read a specific property from a specific file
331
- const target = file ? `file="${file}"` : `path="${path}"`;
332
- 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);
333
283
  }
334
- let cmd = "properties";
335
- if (file) cmd += ` file="${file}"`;
336
- if (path) cmd += ` path="${path}"`;
337
- cmd += " counts";
338
- 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);
339
289
  },
340
290
  );
341
291
 
342
292
  server.tool(
343
293
  "obsidian_create",
344
- "Create a new note.",
294
+ "Create a new note.\n\nParameters:\n name (optional) — file name for the new note\n path (optional) — vault-relative path\n content (optional) — initial markdown content\n template (optional) — template name to use\n\nExamples:\n obsidian_create({ name: \"Meeting 2026-04-03\", content: \"# Meeting Notes\\n\\n- Attendees: ...\" })\n obsidian_create({ path: \"Projects/new-idea.md\", template: \"project\" })",
345
295
  {
346
296
  name: z.string().optional().describe("File name"),
347
297
  path: z.string().optional().describe("File path"),
@@ -349,18 +299,18 @@ server.tool(
349
299
  template: z.string().optional().describe("Template to use"),
350
300
  },
351
301
  async ({ name, path, content, template }) => {
352
- let cmd = "create";
353
- if (name) cmd += ` name="${name}"`;
354
- if (path) cmd += ` path="${path}"`;
355
- if (template) cmd += ` template="${template}"`;
356
- if (content) cmd += ` content="${content.replace(/"/g, '\\"')}"`;
357
- 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);
358
308
  },
359
309
  );
360
310
 
361
311
  server.tool(
362
312
  "obsidian_property_set",
363
- "Set a frontmatter property on a note.",
313
+ "Set a frontmatter property on a note.\n\nParameters:\n name (required) — property name\n value (required) — property value\n file (optional) — note name (wikilink resolution)\n path (optional) — exact file path\n One of file or path is required.\n\nExamples:\n obsidian_property_set({ name: \"status\", value: \"done\", file: \"My Task\" })\n obsidian_property_set({ name: \"tags\", value: \"project, active\", path: \"Work/todo.md\" })",
364
314
  {
365
315
  name: z.string().describe("Property name"),
366
316
  value: z.string().describe("Property value"),
@@ -368,57 +318,103 @@ server.tool(
368
318
  path: z.string().optional().describe("File path"),
369
319
  },
370
320
  async ({ name, value, file, path: filePath }) => {
371
- const target = file ? `file="${file}"` : filePath ? `path="${filePath}"` : "";
372
- if (!target) return text("Error: provide file= or path=");
373
- 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);
374
326
  },
375
327
  );
376
328
 
377
329
  server.tool(
378
330
  "obsidian_backlinks",
379
- "List backlinks to a note.",
331
+ "List backlinks to a note.\n\nParameters:\n file (optional) — note name (wikilink resolution)\n path (optional) — exact file path\n\nExamples:\n obsidian_backlinks({ file: \"Project Plan\" })\n obsidian_backlinks({ path: \"Ideas/brainstorm.md\" })",
380
332
  {
381
333
  file: z.string().optional().describe("File name"),
382
334
  path: z.string().optional().describe("File path"),
383
335
  },
384
336
  async ({ file, path }) => {
385
- const target = file ? `file="${file}"` : path ? `path="${path}"` : "";
386
- 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);
387
342
  },
388
343
  );
389
344
 
390
345
  server.tool(
391
346
  "obsidian_files",
392
- "List files in the vault or a specific folder.",
347
+ "List files in the vault or a specific folder.\n\nParameters:\n folder (optional) — filter by folder path\n ext (optional) — filter by file extension (e.g. 'md', 'canvas')\n\nExamples:\n obsidian_files({})\n obsidian_files({ folder: \"Projects/\", ext: \"md\" })",
393
348
  {
394
349
  folder: z.string().optional().describe("Filter by folder path"),
395
350
  ext: z.string().optional().describe("Filter by extension"),
396
351
  },
397
352
  async ({ folder, ext }) => {
398
- let cmd = "files";
399
- if (folder) cmd += ` folder="${folder}"`;
400
- if (ext) cmd += ` ext=${ext}`;
401
- 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);
402
357
  },
403
358
  );
404
359
 
405
360
  server.tool(
406
361
  "obsidian_recents",
407
- "List recently opened files.",
362
+ "List recently opened files.\n\nReturns the most recently opened files in the vault, ordered by last access time.",
408
363
  {},
409
364
  async () => runTool("recents"),
410
365
  );
411
366
 
367
+ server.tool(
368
+ "obsidian_help",
369
+ "Get Obsidian reference documentation on a topic.\n\nParameters:\n topic (required) — one of: cli, markdown, bases, canvas\n\nExamples:\n obsidian_help({ topic: \"markdown\" }) — wikilinks, embeds, callouts, properties, tags\n obsidian_help({ topic: \"bases\" }) — Bases YAML schema, filters, formulas, views\n obsidian_help({ topic: \"canvas\" }) — JSON Canvas nodes, edges, colors, layout\n obsidian_help({ topic: \"cli\" }) — CLI command syntax and parameter patterns",
370
+ { topic: z.enum(["cli", "markdown", "bases", "canvas"]).describe("Reference topic") },
371
+ async ({ topic }) => ({
372
+ content: [{ type: "text", text: promptContent[`obsidian-${topic}`] }],
373
+ }),
374
+ );
375
+
376
+ // ---- MCP Prompts -----------------------------------------------------------
377
+
378
+ const promptMeta = {
379
+ "obsidian-cli": {
380
+ title: "Obsidian CLI Reference",
381
+ description: "CLI usage patterns, parameter syntax, and command examples for the Obsidian CLI. Adapted from kepano/obsidian-skills (MIT License, https://github.com/kepano/obsidian-skills).",
382
+ },
383
+ "obsidian-markdown": {
384
+ title: "Obsidian Flavored Markdown Reference",
385
+ description: "Wikilinks, embeds, callouts, properties, tags, and other Obsidian-specific markdown syntax. Adapted from kepano/obsidian-skills (MIT License, https://github.com/kepano/obsidian-skills).",
386
+ },
387
+ "obsidian-bases": {
388
+ title: "Obsidian Bases Reference",
389
+ description: "Bases syntax for database-like views: filters, formulas, view types, and summaries. Adapted from kepano/obsidian-skills (MIT License, https://github.com/kepano/obsidian-skills).",
390
+ },
391
+ "obsidian-canvas": {
392
+ title: "JSON Canvas Reference",
393
+ description: "JSON Canvas format for visual canvases: node types, edges, groups, and layout. Adapted from kepano/obsidian-skills (MIT License, https://github.com/kepano/obsidian-skills).",
394
+ },
395
+ };
396
+
397
+ for (const [name, content] of Object.entries(promptContent)) {
398
+ const meta = promptMeta[name];
399
+ server.registerPrompt(
400
+ name,
401
+ { title: meta.title, description: meta.description },
402
+ () => ({
403
+ messages: [{ role: "user", content: { type: "text", text: content } }],
404
+ })
405
+ );
406
+ }
407
+
412
408
  // ---- Start ---------------------------------------------------------------
413
409
 
414
410
  async function main() {
415
- const { running, version } = await checkObsidianRunning();
411
+ const running = await checkObsidianRunning();
416
412
  if (!running) {
417
413
  console.error("Error: Obsidian is not running. Please open Obsidian and try again.");
418
414
  process.exit(1);
419
415
  }
420
416
  const transport = new StdioServerTransport();
421
- console.error(`obsidian-mcp server running on stdio (Obsidian ${version})`);
417
+ console.error("obsidian-mcp server running on stdio");
422
418
  await server.connect(transport);
423
419
  }
424
420
 
@@ -1,15 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(node:*)",
5
- "Bash(git checkout:*)",
6
- "Bash(git push:*)",
7
- "Bash(gh api:*)",
8
- "Bash(gh pr:*)",
9
- "Bash(git pull:*)",
10
- "Bash(git stash:*)",
11
- "Bash(npm version:*)",
12
- "Bash(npm publish:*)"
13
- ]
14
- }
15
- }