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 +7 -3
- package/lib/helpers.js +61 -1
- package/package.json +2 -1
- package/server.js +113 -24
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.
|
|
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 {
|
|
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
|
|
60
|
-
|
|
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
|
|
69
|
-
"
|
|
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\/
|
|
73
|
-
if (match
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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.
|
|
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(
|
|
414
|
-
|
|
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");
|