mcp-obsidian-cli 1.0.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +94 -0
  3. package/package.json +37 -0
  4. package/server.js +397 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matt Stone
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # mcp-obsidian-cli
2
+
3
+ > **Trademark Notice:** "Obsidian" is a trademark of Obsidian Publishing, Inc. This project is not affiliated with or endorsed by Obsidian Publishing.
4
+
5
+ MCP server that wraps the Obsidian CLI, giving AI assistants full access to Obsidian's native API — search index, wikilink resolution, tasks, properties, daily notes, backlinks, and 80+ commands — through the Model Context Protocol.
6
+
7
+ ## Why this exists
8
+
9
+ Every existing Obsidian MCP server takes one of two approaches: the Local REST API plugin (requires API keys, HTTP overhead, limited command surface) or raw filesystem access (no Obsidian awareness — no search index, no wikilink resolution, no task queries). Both miss the point.
10
+
11
+ `mcp-obsidian-cli` wraps the Obsidian CLI plugin, which talks directly to the running Obsidian instance via IPC. This means full access to Obsidian's internal APIs with zero configuration — no API keys, no REST plugins, no token management.
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ npx mcp-obsidian-cli
17
+ ```
18
+
19
+ ## Claude Desktop config
20
+
21
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "obsidian": {
27
+ "command": "npx",
28
+ "args": ["-y", "mcp-obsidian-cli"],
29
+ "env": {
30
+ "OBSIDIAN_VAULT": "my-vault"
31
+ }
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ ## Requirements
38
+
39
+ - Obsidian running with the CLI plugin active
40
+ - `obsidian` on your PATH
41
+ - Node.js >= 18
42
+
43
+ ## How it works
44
+
45
+ The server exposes Obsidian CLI commands as MCP tools. A generic pass-through tool handles the full CLI surface (80+ commands), plus typed convenience tools for common operations:
46
+
47
+ | Tool | Description |
48
+ |------|-------------|
49
+ | `obsidian` | Generic pass-through — run any CLI command |
50
+ | `obsidian_daily_read` | Read today's daily note |
51
+ | `obsidian_daily_append` | Append to daily note |
52
+ | `obsidian_read` | Read a note by name or path |
53
+ | `obsidian_search` | Full-text search with context |
54
+ | `obsidian_tags` | List tags with counts |
55
+ | `obsidian_tasks` | Query tasks (daily, todo, done) |
56
+ | `obsidian_properties` | Read frontmatter properties |
57
+ | `obsidian_create` | Create a new note |
58
+ | `obsidian_property_set` | Set a frontmatter property |
59
+ | `obsidian_backlinks` | List backlinks to a note |
60
+ | `obsidian_files` | List vault files |
61
+ | `obsidian_recents` | Recently opened files |
62
+
63
+ The generic `obsidian` tool means the MCP server never falls behind the CLI — new CLI commands work immediately without a server update.
64
+
65
+ ## Environment variables
66
+
67
+ | Variable | Default | Description |
68
+ |---|---|---|
69
+ | `OBSIDIAN_VAULT` | _(none)_ | Target vault by name |
70
+ | `OBSIDIAN_CLI_PATH` | `obsidian` | Path to CLI binary |
71
+ | `OBSIDIAN_TIMEOUT_MS` | `15000` | Command timeout |
72
+
73
+ ## Compared to alternatives
74
+
75
+ | | mcp-obsidian-cli | REST API servers | Filesystem servers |
76
+ |---|---|---|---|
77
+ | Search index | Yes | Yes | No |
78
+ | Wikilink resolution | Yes | Partial | No |
79
+ | Task queries | Yes | No | No |
80
+ | Property types | Yes | Partial | No |
81
+ | Daily notes | Yes | No | No |
82
+ | Backlinks | Yes | Yes | No |
83
+ | API keys | None | Required | None |
84
+ | Obsidian plugins | CLI plugin | REST API plugin | None |
85
+ | Commands | 80+ | ~10 | ~6 |
86
+
87
+ ## License
88
+
89
+ MIT
90
+
91
+ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Z8Z41G13PX)
92
+
93
+ Maintained by [@stonematt](https://github.com/stonematt)
94
+ Licensed under the MIT License
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "mcp-obsidian-cli",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "MCP server wrapping the Obsidian CLI — full native API access over Model Context Protocol",
6
+ "main": "server.js",
7
+ "bin": {
8
+ "mcp-obsidian-cli": "server.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node server.js",
12
+ "test": "node --test test/run.test.js"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "obsidian",
20
+ "model-context-protocol",
21
+ "cli",
22
+ "claude",
23
+ "ai",
24
+ "knowledge-management"
25
+ ],
26
+ "author": "stonematt",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/stonematt/mcp-obsidian-cli.git"
31
+ },
32
+ "dependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.12.1",
34
+ "js-yaml": "^4.1.0",
35
+ "zod": "^3.25.0"
36
+ }
37
+ }
package/server.js ADDED
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * obsidian-mcp — MCP server wrapping the Obsidian CLI.
4
+ *
5
+ * Exposes a single generic `obsidian` tool that passes any command string
6
+ * directly to the CLI, plus a handful of convenience tools for the most
7
+ * common operations (read, search, daily, tasks, properties, etc.).
8
+ *
9
+ * Environment variables:
10
+ * OBSIDIAN_CLI_PATH - Path to the obsidian CLI binary (default: "obsidian")
11
+ * OBSIDIAN_VAULT - Vault name to use (default: "")
12
+ * OBSIDIAN_TIMEOUT_MS - Command timeout in ms (default: 15000)
13
+ *
14
+ * Requirements:
15
+ * - Obsidian must be running with the CLI plugin active.
16
+ * - The `obsidian` binary must be on PATH (or set OBSIDIAN_CLI_PATH).
17
+ */
18
+
19
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
20
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
21
+ import { z } from "zod";
22
+ import { execFile } from "node:child_process";
23
+ import { promisify } from "node:util";
24
+ import { readFileSync, mkdirSync, existsSync } from "node:fs";
25
+ import { load as yamlLoad } from "js-yaml";
26
+ import { homedir } from "node:os";
27
+ import { join } from "node:path";
28
+ import { exec } from "node:child_process";
29
+
30
+ const execFileAsync = promisify(execFile);
31
+ const execAsync = promisify(exec);
32
+
33
+ const CONFIG_DIR = join(homedir(), ".config", "mcp-obsidian-cli");
34
+ const CONFIG_FILE = join(CONFIG_DIR, "config.yaml");
35
+
36
+ function loadConfig() {
37
+ const defaults = { vault: "", cliPath: "obsidian", timeoutMs: 15000 };
38
+ let config = { ...defaults };
39
+
40
+ if (existsSync(CONFIG_FILE)) {
41
+ try {
42
+ const content = readFileSync(CONFIG_FILE, "utf8");
43
+ const fileConfig = yamlLoad(content);
44
+ if (fileConfig) {
45
+ if (fileConfig.vault) config.vault = fileConfig.vault;
46
+ if (fileConfig.cliPath) config.cliPath = fileConfig.cliPath;
47
+ if (fileConfig.timeoutMs) config.timeoutMs = fileConfig.timeoutMs;
48
+ }
49
+ } catch (err) {
50
+ console.error("Warning: failed to load config file:", err.message);
51
+ }
52
+ }
53
+
54
+ if (process.env.OBSIDIAN_VAULT) config.vault = process.env.OBSIDIAN_VAULT;
55
+ if (process.env.OBSIDIAN_CLI_PATH) config.cliPath = process.env.OBSIDIAN_CLI_PATH;
56
+ if (process.env.OBSIDIAN_TIMEOUT_MS) config.timeoutMs = parseInt(process.env.OBSIDIAN_TIMEOUT_MS, 10);
57
+
58
+ return config;
59
+ }
60
+
61
+ const config = loadConfig();
62
+ const CLI = config.cliPath;
63
+ const VAULT = config.vault;
64
+ const TIMEOUT_MS = config.timeoutMs;
65
+
66
+ async function checkObsidianRunning() {
67
+ try {
68
+ const { stdout: psOut } = await execAsync("ps aux | grep -i obsidian | grep -v grep | grep -v Helper", { timeout: 2000 });
69
+ const obsidianRunning = psOut.includes("/Applications/Obsidian.app");
70
+ if (!obsidianRunning) {
71
+ return { running: false, version: null };
72
+ }
73
+ const { stdout } = await execFileAsync(CLI, ["version"], { timeout: 2000 });
74
+ const hasStartupMsg = stdout.includes("Loaded updated app package") ||
75
+ stdout.includes("Checking for update") ||
76
+ stdout.includes("App is up to date") ||
77
+ stdout.includes("Latest version is");
78
+ if (hasStartupMsg) {
79
+ return { running: false, version: null };
80
+ }
81
+ if (stdout.includes("(installer")) {
82
+ const match = stdout.match(/(\d+\.\d+\.\d+)/);
83
+ if (match) {
84
+ return { running: true, version: match[1] };
85
+ }
86
+ }
87
+ return { running: false, version: null };
88
+ } catch (err) {
89
+ return { running: false, version: null };
90
+ }
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Helpers
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Run the Obsidian CLI with the given argument string.
99
+ * Returns { stdout, stderr } or throws on non-zero exit / timeout.
100
+ */
101
+ async function run(argString) {
102
+ const args = parseArgs(argString);
103
+ if (VAULT) args.push(`vault=${VAULT}`);
104
+
105
+ try {
106
+ const { stdout, stderr } = await execFileAsync(CLI, args, {
107
+ timeout: TIMEOUT_MS,
108
+ maxBuffer: 4 * 1024 * 1024,
109
+ env: { ...process.env },
110
+ });
111
+ return { stdout: stdout.trimEnd(), stderr: stderr.trimEnd(), error: null };
112
+ } catch (err) {
113
+ if (err.code === 'ENOENT') {
114
+ return {
115
+ stdout: '',
116
+ stderr: '',
117
+ error: {
118
+ type: 'CLI_NOT_FOUND',
119
+ message: `Obsidian CLI not found at: ${CLI}. Set OBSIDIAN_CLI_PATH or ensure 'obsidian' is on PATH.`
120
+ }
121
+ };
122
+ }
123
+ if (err.killed) {
124
+ return {
125
+ stdout: '',
126
+ stderr: '',
127
+ error: {
128
+ type: 'TIMEOUT',
129
+ message: `Command timed out after ${TIMEOUT_MS}ms. Set OBSIDIAN_TIMEOUT_MS to increase timeout.`
130
+ }
131
+ };
132
+ }
133
+ const msg = err.stderr?.trimEnd() || err.message;
134
+ return { stdout: '', stderr: '', error: { type: 'EXECUTION_ERROR', message: msg } };
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Minimal arg parser: splits on whitespace but respects key="value with spaces".
140
+ */
141
+ function parseArgs(str) {
142
+ const args = [];
143
+ const re = /(?:[^\s"]+|"[^"]*")+/g;
144
+ let m;
145
+ while ((m = re.exec(str)) !== null) {
146
+ args.push(m[0]);
147
+ }
148
+ return args;
149
+ }
150
+
151
+ /** Standard MCP text result. */
152
+ function text(content) {
153
+ return { content: [{ type: "text", text: content }] };
154
+ }
155
+
156
+ /** Standard MCP error result. */
157
+ function errorResult(content, code = "EXECUTION_ERROR") {
158
+ return {
159
+ content: [{ type: "text", text: content }],
160
+ isError: true,
161
+ };
162
+ }
163
+
164
+ /** Run CLI, return MCP result. */
165
+ async function runTool(argString) {
166
+ const { stdout, stderr, error } = await run(argString);
167
+ if (error) {
168
+ return errorResult(error.message, error.type);
169
+ }
170
+ const parts = [];
171
+ if (stdout) parts.push(stdout);
172
+ if (stderr) parts.push(`[stderr] ${stderr}`);
173
+ return text(parts.join("\n") || "(no output)");
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Server
178
+ // ---------------------------------------------------------------------------
179
+
180
+ const server = new McpServer({
181
+ name: "obsidian-mcp",
182
+ version: "1.0.0",
183
+ capabilities: { tools: {} },
184
+ });
185
+
186
+ // ---- Generic pass-through tool ------------------------------------------
187
+
188
+ server.tool(
189
+ "obsidian",
190
+ `Run any Obsidian CLI command. Pass the full command string exactly as you
191
+ would on the terminal (minus the leading 'obsidian' binary name).
192
+ Examples:
193
+ "daily:read"
194
+ "search:context query=\"meeting notes\" limit=5"
195
+ "read file=\"My Note\""
196
+ "tags counts sort=count"
197
+ "tasks daily"
198
+ "property:read name=status path=\"1p/my-project/my-project.md\""
199
+ "help search"`,
200
+ { command: z.string().describe("CLI command and arguments") },
201
+ async ({ command }) => runTool(command),
202
+ );
203
+
204
+ // ---- Convenience tools for common operations ----------------------------
205
+
206
+ server.tool(
207
+ "obsidian_daily_read",
208
+ "Read today's daily note contents.",
209
+ {},
210
+ async () => runTool("daily:read"),
211
+ );
212
+
213
+ server.tool(
214
+ "obsidian_daily_path",
215
+ "Get the file path of today's daily note.",
216
+ {},
217
+ async () => runTool("daily:path"),
218
+ );
219
+
220
+ server.tool(
221
+ "obsidian_daily_append",
222
+ "Append content to today's daily note.",
223
+ { content: z.string().describe("Content to append") },
224
+ async ({ content }) => runTool(`daily:append content="${content.replace(/"/g, '\\"')}"`),
225
+ );
226
+
227
+ server.tool(
228
+ "obsidian_read",
229
+ "Read a note by file name (wikilink-style) or exact path.",
230
+ {
231
+ file: z.string().optional().describe("File name (wikilink resolution)"),
232
+ path: z.string().optional().describe("Exact file path"),
233
+ },
234
+ async ({ file, path }) => {
235
+ if (!file && !path) return text("Error: provide file= or path=");
236
+ const arg = file ? `file="${file}"` : `path="${path}"`;
237
+ return runTool(`read ${arg}`);
238
+ },
239
+ );
240
+
241
+ server.tool(
242
+ "obsidian_search",
243
+ "Full-text search across the vault with line context.",
244
+ {
245
+ query: z.string().describe("Search query"),
246
+ path: z.string().optional().describe("Limit to folder"),
247
+ limit: z.number().optional().describe("Max files to return"),
248
+ },
249
+ async ({ query, path, limit }) => {
250
+ let cmd = `search:context query="${query.replace(/"/g, '\\"')}"`;
251
+ if (path) cmd += ` path="${path}"`;
252
+ if (limit) cmd += ` limit=${limit}`;
253
+ return runTool(cmd);
254
+ },
255
+ );
256
+
257
+ server.tool(
258
+ "obsidian_tags",
259
+ "List tags in the vault with counts.",
260
+ {
261
+ sort: z.enum(["name", "count"]).optional().describe("Sort order"),
262
+ },
263
+ async ({ sort }) => {
264
+ let cmd = "tags counts";
265
+ if (sort) cmd += ` sort=${sort}`;
266
+ return runTool(cmd);
267
+ },
268
+ );
269
+
270
+ server.tool(
271
+ "obsidian_tasks",
272
+ "List tasks. Use daily=true for today's tasks only.",
273
+ {
274
+ daily: z.boolean().optional().describe("Show only daily note tasks"),
275
+ todo: z.boolean().optional().describe("Show incomplete tasks only"),
276
+ done: z.boolean().optional().describe("Show completed tasks only"),
277
+ path: z.string().optional().describe("Filter by file path"),
278
+ },
279
+ async ({ daily, todo, done, path }) => {
280
+ let cmd = "tasks";
281
+ if (daily) cmd += " daily";
282
+ if (todo) cmd += " todo";
283
+ if (done) cmd += " done";
284
+ if (path) cmd += ` path="${path}"`;
285
+ return runTool(cmd);
286
+ },
287
+ );
288
+
289
+ server.tool(
290
+ "obsidian_properties",
291
+ "List or read frontmatter properties.",
292
+ {
293
+ file: z.string().optional().describe("File name"),
294
+ path: z.string().optional().describe("File path"),
295
+ name: z.string().optional().describe("Specific property name to read"),
296
+ },
297
+ async ({ file, path, name }) => {
298
+ if (name && (file || path)) {
299
+ // Read a specific property from a specific file
300
+ const target = file ? `file="${file}"` : `path="${path}"`;
301
+ return runTool(`property:read name="${name}" ${target}`);
302
+ }
303
+ let cmd = "properties";
304
+ if (file) cmd += ` file="${file}"`;
305
+ if (path) cmd += ` path="${path}"`;
306
+ cmd += " counts";
307
+ return runTool(cmd);
308
+ },
309
+ );
310
+
311
+ server.tool(
312
+ "obsidian_create",
313
+ "Create a new note.",
314
+ {
315
+ name: z.string().optional().describe("File name"),
316
+ path: z.string().optional().describe("File path"),
317
+ content: z.string().optional().describe("Initial content"),
318
+ template: z.string().optional().describe("Template to use"),
319
+ },
320
+ async ({ name, path, content, template }) => {
321
+ let cmd = "create";
322
+ if (name) cmd += ` name="${name}"`;
323
+ if (path) cmd += ` path="${path}"`;
324
+ if (template) cmd += ` template="${template}"`;
325
+ if (content) cmd += ` content="${content.replace(/"/g, '\\"')}"`;
326
+ return runTool(cmd);
327
+ },
328
+ );
329
+
330
+ server.tool(
331
+ "obsidian_property_set",
332
+ "Set a frontmatter property on a note.",
333
+ {
334
+ name: z.string().describe("Property name"),
335
+ value: z.string().describe("Property value"),
336
+ file: z.string().optional().describe("File name"),
337
+ path: z.string().optional().describe("File path"),
338
+ },
339
+ async ({ name, value, file, path: filePath }) => {
340
+ const target = file ? `file="${file}"` : filePath ? `path="${filePath}"` : "";
341
+ if (!target) return text("Error: provide file= or path=");
342
+ return runTool(`property:set name="${name}" value="${value.replace(/"/g, '\\"')}" ${target}`);
343
+ },
344
+ );
345
+
346
+ server.tool(
347
+ "obsidian_backlinks",
348
+ "List backlinks to a note.",
349
+ {
350
+ file: z.string().optional().describe("File name"),
351
+ path: z.string().optional().describe("File path"),
352
+ },
353
+ async ({ file, path }) => {
354
+ const target = file ? `file="${file}"` : path ? `path="${path}"` : "";
355
+ return runTool(`backlinks ${target} counts`);
356
+ },
357
+ );
358
+
359
+ server.tool(
360
+ "obsidian_files",
361
+ "List files in the vault or a specific folder.",
362
+ {
363
+ folder: z.string().optional().describe("Filter by folder path"),
364
+ ext: z.string().optional().describe("Filter by extension"),
365
+ },
366
+ async ({ folder, ext }) => {
367
+ let cmd = "files";
368
+ if (folder) cmd += ` folder="${folder}"`;
369
+ if (ext) cmd += ` ext=${ext}`;
370
+ return runTool(cmd);
371
+ },
372
+ );
373
+
374
+ server.tool(
375
+ "obsidian_recents",
376
+ "List recently opened files.",
377
+ {},
378
+ async () => runTool("recents"),
379
+ );
380
+
381
+ // ---- Start ---------------------------------------------------------------
382
+
383
+ async function main() {
384
+ const { running, version } = await checkObsidianRunning();
385
+ if (!running) {
386
+ console.error("Error: Obsidian is not running. Please open Obsidian and try again.");
387
+ process.exit(1);
388
+ }
389
+ const transport = new StdioServerTransport();
390
+ console.error(`obsidian-mcp server running on stdio (Obsidian ${version})`);
391
+ await server.connect(transport);
392
+ }
393
+
394
+ main().catch((err) => {
395
+ console.error("Fatal:", err);
396
+ process.exit(1);
397
+ });