mcp-obsidian-cli 1.2.0 → 1.3.1

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 CHANGED
@@ -37,7 +37,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
37
37
  ## Requirements
38
38
 
39
39
  - Obsidian running with the CLI plugin active
40
- - `obsidian` on your PATH
40
+ - `obsidian-cli` on your PATH (typically `/Applications/Obsidian.app/Contents/MacOS/obsidian-cli` on macOS)
41
41
  - Node.js >= 18
42
42
 
43
43
  ## How it works
@@ -67,7 +67,7 @@ The generic `obsidian` tool means the MCP server never falls behind the CLI —
67
67
  | Variable | Default | Description |
68
68
  |---|---|---|
69
69
  | `OBSIDIAN_VAULT` | _(none)_ | Target vault by name |
70
- | `OBSIDIAN_CLI_PATH` | `obsidian` | Path to CLI binary |
70
+ | `OBSIDIAN_CLI_PATH` | `obsidian-cli` | Path to CLI binary |
71
71
  | `OBSIDIAN_TIMEOUT_MS` | `15000` | Command timeout |
72
72
  | `XDG_CONFIG_HOME` | `~/.config` | Base path for config file |
73
73
 
@@ -81,7 +81,7 @@ The server can read settings from a YAML config file:
81
81
  Config file format:
82
82
  ```yaml
83
83
  vault: "my-vault"
84
- cliPath: "obsidian"
84
+ cliPath: "obsidian-cli"
85
85
  timeoutMs: 15000
86
86
  ```
87
87
 
@@ -101,6 +101,10 @@ Config precedence: env vars > config file > hardcoded defaults
101
101
  | Obsidian plugins | CLI plugin | REST API plugin | None |
102
102
  | Commands | 80+ | ~10 | ~6 |
103
103
 
104
+ ## Bugs / requests
105
+
106
+ File an issue: https://github.com/stonematt/mcp-obsidian-cli/issues/new/choose. Bug template asks for version, MCP client, tool call, and response — quick to fill, fast to act on.
107
+
104
108
  ## License
105
109
 
106
110
  MIT
package/lib/helpers.js CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { readFileSync, existsSync } from "node:fs";
6
+ import { basename } from "node:path";
6
7
  import { load as yamlLoad } from "js-yaml";
7
8
 
8
9
  /**
@@ -11,7 +12,7 @@ import { load as yamlLoad } from "js-yaml";
11
12
  * @returns {{ vault: string, cliPath: string, timeoutMs: number }}
12
13
  */
13
14
  export function loadConfig(configFile) {
14
- const defaults = { vault: "", cliPath: "obsidian", timeoutMs: 15000 };
15
+ const defaults = { vault: "", cliPath: "obsidian-cli", timeoutMs: 15000 };
15
16
  let config = { ...defaults };
16
17
 
17
18
  if (existsSync(configFile)) {
@@ -48,6 +49,65 @@ export function parseArgs(str) {
48
49
  return args;
49
50
  }
50
51
 
52
+ /**
53
+ * Build CLI argv with vault= prepended when configured. Caller-supplied
54
+ * `vault=` (must be first token) wins — the configured vault is skipped so
55
+ * per-call overrides through the generic `obsidian` tool remain reachable.
56
+ */
57
+ export function buildCliArgs(input, vault) {
58
+ const args = Array.isArray(input) ? [...input] : parseArgs(input);
59
+ if (vault && !args[0]?.startsWith("vault=")) {
60
+ args.unshift(`vault=${vault}`);
61
+ }
62
+ return args;
63
+ }
64
+
65
+ /**
66
+ * Load the set of vault names Obsidian knows about by parsing the desktop
67
+ * app's registry file. Returns an empty Set if the file is missing or
68
+ * unreadable (non-macOS, fresh install, etc.) — callers should treat that
69
+ * case as "no validation possible, trust the configured vault".
70
+ *
71
+ * @param {string} obsidianJsonPath - Path to Obsidian's vaults registry.
72
+ * @returns {Set<string>} basenames of known vault paths.
73
+ */
74
+ export function loadKnownVaults(obsidianJsonPath) {
75
+ if (!existsSync(obsidianJsonPath)) return new Set();
76
+ try {
77
+ const content = readFileSync(obsidianJsonPath, "utf8");
78
+ const data = JSON.parse(content);
79
+ const vaults = data?.vaults || {};
80
+ const names = new Set();
81
+ for (const v of Object.values(vaults)) {
82
+ if (v?.path) names.add(basename(v.path));
83
+ }
84
+ return names;
85
+ } catch {
86
+ return new Set();
87
+ }
88
+ }
89
+
90
+ /**
91
+ * If the first token of args is `vault=NAME`, return NAME. Otherwise null.
92
+ * Mirrors the CLI's requirement that `vault=` be the first positional token
93
+ * when overriding the focused vault.
94
+ *
95
+ * @param {string[]} args - Parsed CLI args.
96
+ * @returns {string|null}
97
+ */
98
+ export function extractLeadingVault(args) {
99
+ const first = args[0];
100
+ if (typeof first === "string" && first.startsWith("vault=")) {
101
+ return first.slice("vault=".length);
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /** ENOENT error string for a missing CLI binary. */
107
+ export function cliNotFoundMessage(cli) {
108
+ return `Obsidian CLI not found at: ${cli}. Set OBSIDIAN_CLI_PATH or ensure '${cli}' is on PATH.`;
109
+ }
110
+
51
111
  /** Standard MCP text result. */
52
112
  export function text(content) {
53
113
  return { content: [{ type: "text", text: content }] };
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "mcp-obsidian-cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
+ "mcpName": "io.github.stonematt/mcp-obsidian-cli",
4
5
  "type": "module",
5
6
  "description": "MCP server wrapping the Obsidian CLI — full native API access over Model Context Protocol",
6
7
  "main": "server.js",
package/server.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * common operations (read, search, daily, tasks, properties, etc.).
8
8
  *
9
9
  * Environment variables:
10
- * OBSIDIAN_CLI_PATH - Path to the obsidian CLI binary (default: "obsidian")
10
+ * OBSIDIAN_CLI_PATH - Path to the obsidian CLI binary (default: "obsidian-cli")
11
11
  * OBSIDIAN_VAULT - Vault name to use (default: "")
12
12
  * OBSIDIAN_TIMEOUT_MS - Command timeout in ms (default: 15000)
13
13
  *
@@ -27,7 +27,16 @@ import { homedir } from "node:os";
27
27
  import { join, dirname } from "node:path";
28
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
+ import {
31
+ loadConfig,
32
+ text,
33
+ errorResult,
34
+ buildCliArgs,
35
+ cliNotFoundMessage,
36
+ loadKnownVaults,
37
+ extractLeadingVault,
38
+ parseArgs,
39
+ } from "./lib/helpers.js";
31
40
 
32
41
  const execFileAsync = promisify(execFile);
33
42
  const execAsync = promisify(exec);
@@ -48,16 +57,17 @@ const CONFIG_FILE = join(CONFIG_DIR, "config.yaml");
48
57
 
49
58
 
50
59
  const KNOWN_CLI_PATHS = [
51
- "/Applications/Obsidian.app/Contents/MacOS/obsidian",
52
- join(homedir(), "Applications/Obsidian.app/Contents/MacOS/obsidian"),
60
+ "/Applications/Obsidian.app/Contents/MacOS/obsidian-cli",
61
+ join(homedir(), "Applications/Obsidian.app/Contents/MacOS/obsidian-cli"),
53
62
  ];
54
63
 
55
64
  async function resolveCliPath(configured) {
56
- if (configured !== "obsidian") return configured;
65
+ if (configured !== "obsidian-cli") return configured;
57
66
 
58
67
  try {
59
- await execAsync("which obsidian", { timeout: 2000 });
60
- return configured;
68
+ const { stdout } = await execFileAsync("which", ["obsidian-cli"], { timeout: 2000 });
69
+ const resolved = stdout.trim();
70
+ if (resolved) return resolved;
61
71
  } catch { /* not on PATH */ }
62
72
 
63
73
  for (const p of KNOWN_CLI_PATHS) {
@@ -65,12 +75,16 @@ async function resolveCliPath(configured) {
65
75
  }
66
76
 
67
77
  try {
68
- const { stdout } = await execAsync(
69
- "ps aux | grep -i obsidian | grep -v grep | grep -v Helper",
78
+ const { stdout } = await execFileAsync(
79
+ "pgrep",
80
+ ["-lf", "/Applications/Obsidian.app/Contents/MacOS/Obsidian$"],
70
81
  { timeout: 2000 }
71
82
  );
72
- const match = stdout.match(/(\S*\/Contents\/MacOS\/obsidian)/i);
73
- if (match && existsSync(match[1])) return match[1];
83
+ const match = stdout.match(/(\S*\/Contents\/MacOS\/)Obsidian/i);
84
+ if (match) {
85
+ const cliPath = `${match[1]}obsidian-cli`;
86
+ if (existsSync(cliPath)) return cliPath;
87
+ }
74
88
  } catch { /* no running process */ }
75
89
 
76
90
  return configured;
@@ -78,19 +92,60 @@ async function resolveCliPath(configured) {
78
92
 
79
93
  const config = loadConfig(CONFIG_FILE);
80
94
  const CLI = await resolveCliPath(config.cliPath);
81
- const VAULT = config.vault;
82
95
  const TIMEOUT_MS = config.timeoutMs;
83
96
 
97
+ const OBSIDIAN_REGISTRY = join(
98
+ homedir(),
99
+ "Library/Application Support/obsidian/obsidian.json"
100
+ );
101
+ const KNOWN_VAULTS = loadKnownVaults(OBSIDIAN_REGISTRY);
102
+
103
+ // Runtime vault selection. Held in process memory only — not persisted.
104
+ // Initialized from config/env when that value names a known vault; otherwise
105
+ // null, which triggers the prompt-on-first-use flow. Caller-supplied
106
+ // `vault=NAME` in a generic `obsidian` call overrides + caches.
107
+ let runtimeVault = null;
108
+ if (config.vault) {
109
+ if (KNOWN_VAULTS.size === 0 || KNOWN_VAULTS.has(config.vault)) {
110
+ runtimeVault = config.vault;
111
+ } else {
112
+ console.error(
113
+ `Warning: configured OBSIDIAN_VAULT='${config.vault}' is not in Obsidian's known vaults ` +
114
+ `(${[...KNOWN_VAULTS].join(", ") || "<none detected>"}). ` +
115
+ `Server will ask which vault to use on first tool call.`
116
+ );
117
+ }
118
+ }
119
+
120
+ function vaultPromptResponse() {
121
+ const list = [...KNOWN_VAULTS].sort().map((v) => ` - ${v}`).join("\n");
122
+ return text(
123
+ `No vault selected. Available vaults:\n${list}\n\n` +
124
+ `Ask the user which vault to use, then either:\n` +
125
+ ` - retry through the generic \`obsidian\` tool with \`vault=NAME\` as the first token (e.g. \`vault=tyee read file="My Note"\`), or\n` +
126
+ ` - retry any convenience tool — the server will cache the vault from the first \`vault=\` override and reuse it for subsequent calls.\n\n` +
127
+ `If the user named a vault in conversation (e.g. "save this in tyee"), prepend \`vault=tyee\` automatically.`
128
+ );
129
+ }
130
+
131
+ const OBSIDIAN_PROCESS_PATTERN = "/Applications/Obsidian.app/Contents/MacOS/Obsidian$";
132
+ const RUNNING_CACHE_TTL_MS = 5000;
133
+ let runningCache = { value: null, at: 0 };
134
+
84
135
  async function checkObsidianRunning() {
136
+ const now = Date.now();
137
+ if (runningCache.value !== null && now - runningCache.at < RUNNING_CACHE_TTL_MS) {
138
+ return runningCache.value;
139
+ }
140
+ let running = false;
85
141
  try {
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");
142
+ await execFileAsync("pgrep", ["-f", OBSIDIAN_PROCESS_PATTERN], { timeout: 2000 });
143
+ running = true;
91
144
  } catch {
92
- return false;
145
+ running = false;
93
146
  }
147
+ runningCache = { value: running, at: now };
148
+ return running;
94
149
  }
95
150
 
96
151
  // ---------------------------------------------------------------------------
@@ -102,8 +157,7 @@ async function checkObsidianRunning() {
102
157
  * Returns { stdout, stderr } or throws on non-zero exit / timeout.
103
158
  */
104
159
  async function run(input) {
105
- const args = Array.isArray(input) ? [...input] : parseArgs(input);
106
- if (VAULT) args.push(`vault=${VAULT}`);
160
+ const args = buildCliArgs(input, runtimeVault);
107
161
 
108
162
  try {
109
163
  const { stdout, stderr } = await execFileAsync(CLI, args, {
@@ -119,7 +173,7 @@ async function run(input) {
119
173
  stderr: '',
120
174
  error: {
121
175
  type: 'CLI_NOT_FOUND',
122
- message: `Obsidian CLI not found at: ${CLI}. Set OBSIDIAN_CLI_PATH or ensure 'obsidian' is on PATH.`
176
+ message: cliNotFoundMessage(CLI),
123
177
  }
124
178
  };
125
179
  }
@@ -141,6 +195,29 @@ async function run(input) {
141
195
 
142
196
  /** Run CLI, return MCP result. Accepts a command string or an args array. */
143
197
  async function runTool(input) {
198
+ if (!(await checkObsidianRunning())) {
199
+ return errorResult(
200
+ "Obsidian.app is not running. Open Obsidian and retry — no Claude Desktop restart needed.",
201
+ "OBSIDIAN_NOT_RUNNING"
202
+ );
203
+ }
204
+
205
+ // Cache caller-supplied vault override so subsequent convenience-tool calls
206
+ // route to the same vault without the caller having to repeat it.
207
+ const parsed = Array.isArray(input) ? input : parseArgs(input);
208
+ const callerVault = extractLeadingVault(parsed);
209
+ if (callerVault) {
210
+ if (KNOWN_VAULTS.size > 0 && !KNOWN_VAULTS.has(callerVault)) {
211
+ return errorResult(
212
+ `Unknown vault '${callerVault}'. Known vaults: ${[...KNOWN_VAULTS].sort().join(", ")}.`,
213
+ "VAULT_NOT_FOUND"
214
+ );
215
+ }
216
+ runtimeVault = callerVault;
217
+ } else if (!runtimeVault && KNOWN_VAULTS.size > 0) {
218
+ return vaultPromptResponse();
219
+ }
220
+
144
221
  const { stdout, stderr, error } = await run(input);
145
222
  if (error) {
146
223
  return errorResult(error.message, error.type);
@@ -157,7 +234,7 @@ async function runTool(input) {
157
234
 
158
235
  const server = new McpServer({
159
236
  name: "obsidian-mcp",
160
- version: "1.2.0",
237
+ version: "1.3.1",
161
238
  capabilities: { tools: {} },
162
239
  });
163
240
 
@@ -167,6 +244,16 @@ server.tool(
167
244
  "obsidian",
168
245
  `Run any Obsidian CLI command. Pass the full command string exactly as you
169
246
  would on the terminal (minus the leading 'obsidian' binary name).
247
+
248
+ IMPORTANT: when multiple vaults are loaded, the CLI's vault= argument must
249
+ be the FIRST token. The server auto-prepends vault=<runtimeVault> once a
250
+ vault has been selected; if you include vault= manually in this command
251
+ string, put it first or the CLI silently routes to the focused vault.
252
+
253
+ VAULT ROUTING: if the user names a vault in conversation (e.g. "save this
254
+ in tyee", "scarp note"), prepend \`vault=NAME \` as the first token. The
255
+ server caches that selection in memory for subsequent calls.
256
+
170
257
  Examples:
171
258
  "daily:read"
172
259
  "search:context query=\"meeting notes\" limit=5"
@@ -174,6 +261,7 @@ Examples:
174
261
  "tags counts sort=count"
175
262
  "tasks daily"
176
263
  "property:read name=status path=\"1p/my-project/my-project.md\""
264
+ "vault=tyee read file=\"My Note\"" # explicit vault override, first
177
265
  "help search"`,
178
266
  { command: z.string().describe("CLI command and arguments") },
179
267
  async ({ command }) => runTool(command),
@@ -410,8 +498,9 @@ for (const [name, content] of Object.entries(promptContent)) {
410
498
  async function main() {
411
499
  const running = await checkObsidianRunning();
412
500
  if (!running) {
413
- console.error("Error: Obsidian is not running. Please open Obsidian and try again.");
414
- process.exit(1);
501
+ console.error(
502
+ "Warning: Obsidian.app not detected. Server will accept connections; tool calls will fail until Obsidian is opened."
503
+ );
415
504
  }
416
505
  const transport = new StdioServerTransport();
417
506
  console.error("obsidian-mcp server running on stdio");