obsidian-mcp-brain 0.4.0 → 0.5.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 (2) hide show
  1. package/README.md +457 -0
  2. package/package.json +5 -4
package/README.md ADDED
@@ -0,0 +1,457 @@
1
+ # obsidian-mcp-brain
2
+
3
+ A thin Node.js HTTP server that provides remote MCP access to an Obsidian vault.
4
+ It implements all MCP tools natively and exposes them to remote clients such as
5
+ Claude Code and Claude Desktop via HTTP.
6
+
7
+ For the full project (including `mcp-shim`, a Claude Desktop relay) and
8
+ development setup, see the [repo README](https://github.com/a1ecbr0wn/obsidian-mcp-brain#readme).
9
+
10
+ ## Why this server is needed
11
+
12
+ Remote MCP clients (Claude Code 2.x, Claude Desktop) connect over HTTP, not stdio,
13
+ and the standard approaches have issues:
14
+
15
+ ### No HTTP transport for local MCP servers
16
+
17
+ Existing solutions like [`mcp-proxy`](https://github.com/sparfenyuk/mcp-proxy) wrap
18
+ stdio MCP servers and expose them over HTTP. However, mcp-proxy has a session-management
19
+ bug: when Claude Code opens its GET `/mcp` notification stream at the same time
20
+ as sending tool-list requests (which it always does), mcp-proxy's response routing
21
+ gets confused and `tools/list` silently times out. This server implements the MCP
22
+ HTTP transport layer directly and implements all tools natively, eliminating that
23
+ class of bug.
24
+
25
+ ### No OAuth 2.0 discovery
26
+
27
+ The [MCP 2025-03-26 spec](https://spec.modelcontextprotocol.io) requires every
28
+ non-localhost remote MCP server to expose OAuth 2.0 discovery endpoints
29
+ (`/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server`,
30
+ `/authorize`, `/token`, `/register`). Without them, Claude Code refuses to connect.
31
+ This server serves a public (no credentials required) OAuth flow so that Claude
32
+ Code's auth handshake completes without needing real credentials.
33
+
34
+ ### What this server does
35
+
36
+ ```text
37
+ Claude Code / Claude Desktop
38
+ │ HTTPS (e.g. Tailscale)
39
+ ▼
40
+ obsidian-mcp-brain :3002
41
+ ├─ OAuth 2.0 discovery endpoints
42
+ ├─ MCP Streamable HTTP transport (POST /mcp, GET /mcp)
43
+ ├─ Path deny list (access control)
44
+ └─ Vault access (node:fs/promises)
45
+ │
46
+ ▼
47
+ Obsidian vault (filesystem)
48
+ ```
49
+
50
+ ---
51
+
52
+ ### Prerequisites
53
+
54
+ - Node.js 18+
55
+ - A way to expose the server over HTTPS to your remote client —
56
+ [Tailscale Serve](https://tailscale.com/kb/1312/serve) is what I use, but any
57
+ HTTPS reverse proxy works
58
+ - **Optional:** The `query-graph` tool (which queries a vault using a natural-language
59
+ knowledge graph) requires the `graphify` CLI to be installed and on `PATH`, and
60
+ a knowledge graph to be pre-built for that vault (run `graphify --obsidian`
61
+ against it once, before calling the tool). `query-graph` is always listed, but
62
+ calling it against a vault with no graph returns a clear error rather than an
63
+ answer.
64
+
65
+ ---
66
+
67
+ ### Installation
68
+
69
+ ```bash
70
+ npm install -g obsidian-mcp-brain
71
+ ```
72
+
73
+ This puts an `obsidian-mcp-brain` command on your `PATH`. Then set it up as a
74
+ persistent service.
75
+
76
+ ### systemd (Linux)
77
+
78
+ First, create your config file — see [Configuration](#configuration) below for its
79
+ full shape. By default the server reads `~/.config/obsidian-mcp.json`, so no extra
80
+ environment variable is needed unless you want the config somewhere else.
81
+
82
+ Then create `~/.config/systemd/user/obsidian-mcp.service`:
83
+
84
+ ```ini
85
+ [Unit]
86
+ Description=Obsidian MCP Server
87
+ After=network.target
88
+
89
+ [Service]
90
+ ExecStart=/usr/bin/env obsidian-mcp-brain
91
+ Restart=on-failure
92
+ RestartSec=5
93
+
94
+ [Install]
95
+ WantedBy=default.target
96
+ ```
97
+
98
+ (If your config file lives somewhere other than `~/.config/obsidian-mcp.json`, add
99
+ `Environment=CONFIG_PATH=/path/to/your/config.json` under `[Service]`.)
100
+
101
+ Then enable and start it:
102
+
103
+ ```bash
104
+ systemctl --user daemon-reload
105
+ systemctl --user enable --now obsidian-mcp.service
106
+ ```
107
+
108
+ ### macOS (launchd)
109
+
110
+ Create `~/Library/LaunchAgents/com.obsidian-mcp-brain.plist`:
111
+
112
+ ```xml
113
+ <?xml version="1.0" encoding="UTF-8"?>
114
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
115
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
116
+ <plist version="1.0">
117
+ <dict>
118
+ <key>Label</key>
119
+ <string>com.obsidian-mcp-brain</string>
120
+
121
+ <key>ProgramArguments</key>
122
+ <array>
123
+ <string>/usr/local/bin/obsidian-mcp-brain</string>
124
+ </array>
125
+
126
+ <!-- Only needed if your config file isn't at the default
127
+ ~/.config/obsidian-mcp.json -->
128
+ <key>EnvironmentVariables</key>
129
+ <dict>
130
+ <key>CONFIG_PATH</key>
131
+ <string>/path/to/your/obsidian-mcp.json</string>
132
+ </dict>
133
+
134
+ <key>RunAtLoad</key>
135
+ <true/>
136
+ <key>KeepAlive</key>
137
+ <true/>
138
+
139
+ <key>StandardOutPath</key>
140
+ <string>/tmp/obsidian-mcp-brain.log</string>
141
+ <key>StandardErrorPath</key>
142
+ <string>/tmp/obsidian-mcp-brain.log</string>
143
+ </dict>
144
+ </plist>
145
+ ```
146
+
147
+ Load it:
148
+
149
+ ```bash
150
+ launchctl load ~/Library/LaunchAgents/com.obsidian-mcp-brain.plist
151
+ ```
152
+
153
+ To restart after a config change:
154
+
155
+ ```bash
156
+ launchctl unload ~/Library/LaunchAgents/com.obsidian-mcp-brain.plist
157
+ launchctl load ~/Library/LaunchAgents/com.obsidian-mcp-brain.plist
158
+ ```
159
+
160
+ ### Tailscale Serve (HTTPS tunnel)
161
+
162
+ Point Tailscale at the server's local port:
163
+
164
+ ```bash
165
+ sudo tailscale serve --bg --https 4001 http://localhost:3002
166
+ ```
167
+
168
+ This exposes the server at `https://<your-tailscale-hostname>:4001`.
169
+
170
+ ---
171
+
172
+ ## Configuration
173
+
174
+ All configuration lives in one JSON file — no environment variables are read except
175
+ `CONFIG_PATH`, which says where to find it.
176
+
177
+ - **Location**: `CONFIG_PATH` env var if set, otherwise `~/.config/obsidian-mcp.json`.
178
+ - The server serves one or more named vaults from a single process; a client picks
179
+ which one a call applies to via the `vault` argument every tool already takes.
180
+
181
+ ### Shape
182
+
183
+ ```json
184
+ {
185
+ "listenPort": 3002,
186
+ "mcpBaseUrl": "https://your-hostname:4001",
187
+ "denyPaths": ["private"],
188
+ "graphifyQueryTimeoutMs": 60000,
189
+ "fetchMaxBytes": 10485760,
190
+ "fetchTimeoutMs": 30000,
191
+ "vaults": {
192
+ "knowledge": {
193
+ "path": "/path/to/your/obsidian/vault"
194
+ },
195
+ "work": {
196
+ "path": "/path/to/another/vault",
197
+ "denyPaths": ["confidential"]
198
+ }
199
+ }
200
+ }
201
+ ```
202
+
203
+ | Field | Required | Default | Description |
204
+ | ------------------------ | -------- | ---------- | ----------------------------------------------------------------------------------------- |
205
+ | `mcpBaseUrl` | Yes | — | Public HTTPS base URL of the server (used in OAuth responses and SSE endpoint events) |
206
+ | `vaults` | Yes | — | Non-empty object of `{ "name": { "path": "..." } }`. Each vault needs at least a `path` |
207
+ | `listenPort` | No | `3002` | Local port the server listens on |
208
+ | `denyPaths` | No | `[]` | Vault-relative paths to block, applied to every vault. See below |
209
+ | `graphifyQueryTimeoutMs` | No | `60000` | Timeout for a `graphify query` subprocess (milliseconds) |
210
+ | `fetchMaxBytes` | No | `10485760` | Default max response size for `fetch-binary-file` (bytes); overridable per call |
211
+ | `fetchTimeoutMs` | No | `30000` | Default request timeout for `fetch-binary-file` (milliseconds); overridable per call |
212
+
213
+ Each vault entry can also set its own `denyPaths`, which are added on top of the
214
+ global list for that vault only (see below).
215
+
216
+ ### Path deny list (`denyPaths`)
217
+
218
+ `denyPaths` lets you prevent the MCP client from reading or writing specific
219
+ folders in a vault. Paths are relative to the vault root and prefix-matched, so
220
+ denying `people` blocks `people/`, `people/alice/notes.md`, and so on.
221
+
222
+ The top-level `denyPaths` applies to every configured vault. A vault's own
223
+ `denyPaths` (if any) is added on top of that global list, restricting that vault
224
+ further without affecting any other vault:
225
+
226
+ ```json
227
+ {
228
+ "denyPaths": ["private"],
229
+ "vaults": {
230
+ "knowledge": { "path": "/export/knowledge" },
231
+ "work": { "path": "/export/work", "denyPaths": ["confidential", "drafts"] }
232
+ }
233
+ }
234
+ ```
235
+
236
+ Here, both vaults block `private`; `work` additionally blocks `confidential` and
237
+ `drafts`, while `knowledge` is unaffected by that extra restriction.
238
+
239
+ The deny list is enforced in the server before any tool handler executes.
240
+ Blocked requests receive a structured MCP error (`isError: true`) rather than a
241
+ transport-level failure, so the client can report the reason clearly.
242
+
243
+ Affected tools: `read-note`, `create-note`, `edit-note`, `delete-note`, `move-note`
244
+ (source and destination), `create-binary-file`, `fetch-binary-file`, `delete-binary-file`,
245
+ `move-binary-file` (source and destination), `find-backlinks`, `resolve-wikilink`,
246
+ `add-tags`, `remove-tags`, `set-frontmatter-field`, `remove-frontmatter-field`,
247
+ `create-folder`, `search-vault` (when a `path` scope is given).
248
+
249
+ `list-notes`, `list-tags`, `search-tags`, `new-notes`, `changed-notes`, and `rename-tag`
250
+ are equally protected, just via a different mechanism: instead of a single check up
251
+ front, they filter out denied files individually as they walk the vault.
252
+
253
+ `list-vaults` doesn't touch vault files at all — it just returns configured
254
+ vault names — so it's the only tool genuinely unaffected by the deny list.
255
+ `query-graph` takes a free-text question rather than a vault path, so it isn't subject
256
+ to the deny list either.
257
+
258
+ ### Write preconditions (`expectedMtime`)
259
+
260
+ Every tool that writes to an existing file accepts an optional `expectedMtime`: the
261
+ ISO 8601 last-modified timestamp you previously got back from `read-note` or
262
+ `list-notes`. Pass it back on a later write and the server refuses the write — with
263
+ no changes made — if the file's mtime has moved since, telling you both the
264
+ timestamp you expected and its actual current one so you know to re-read and retry.
265
+
266
+ This is a compare-and-swap check, not a lock: it's meant to catch "I read this note
267
+ a while ago, and something else touched it since," not to serialize concurrent
268
+ writers. It's optional so existing callers are unaffected, but a client following a
269
+ read-then-write pattern (read a note, decide what to change, write it back) should
270
+ always pass it — otherwise an edit made by something else in between is silently
271
+ overwritten.
272
+
273
+ ```
274
+ read-note → note content + Last-Modified: 2026-09-20T10:15:00.000Z
275
+ ...decide what to change...
276
+ edit-note → { operation: "replace", content: "...", expectedMtime: "2026-09-20T10:15:00.000Z" }
277
+ ```
278
+
279
+ Applies to: `edit-note` (all operations), `delete-note`, `move-note` (checked against
280
+ the source file), `set-frontmatter-field`, `remove-frontmatter-field`,
281
+ `move-binary-file` and `delete-binary-file` (checked against the source file). For
282
+ `add-tags`/`remove-tags`, which operate on a `files[]` array, `expectedMtime` is
283
+ instead an object mapping each vault-relative path to its expected timestamp; every
284
+ listed file's precondition is checked before any file in the batch is written, so
285
+ the batch either applies wholly or not at all.
286
+
287
+ Not applicable to `create-note`, `create-binary-file`, or `fetch-binary-file`, which
288
+ already fail if the destination exists, nor to `rename-tag`, which sweeps the whole
289
+ vault rather than targeting one file.
290
+
291
+ ### edit-note operations
292
+
293
+ Beyond `append`, `prepend`, and `replace`, `edit-note` supports targeted,
294
+ section-scoped edits so a large note doesn't need to be resent in full for a small
295
+ change:
296
+
297
+ - **`replace-section`** — replaces the content under a heading (matched by exact
298
+ text), leaving the heading line itself in place.
299
+ - **`delete-section`** — removes a heading and everything under it, heading line
300
+ included.
301
+ - **`toggle-checkbox`** — flips (or explicitly sets) a `- [ ]`/`- [x]` line, matched
302
+ by its exact text.
303
+
304
+ If a `heading` or `taskText` match isn't unique in the note, the call fails with a
305
+ list of every match (line number, and heading level where relevant); pass the
306
+ 1-based `occurrence` from that list on a follow-up call to disambiguate.
307
+
308
+ ### fetch-binary-file
309
+
310
+ `create-binary-file` requires the client to send the file as base64 — expensive
311
+ through an LLM client, since base64 is read in and written out again on top of its
312
+ already-larger-than-binary size. `fetch-binary-file` instead has the server download
313
+ a URL itself and write the result, so the client only ever sends a URL string.
314
+
315
+ ```
316
+ fetch-binary-file → { filename: "photo.jpg", folder: "attachments", url: "https://example.com/photo.jpg" }
317
+ ```
318
+
319
+ Because the server performs the request itself, a caller-supplied URL is effectively
320
+ asking this host to make an arbitrary outbound call — on any deployment, this host
321
+ may be able to reach private network services that shouldn't be exposed to a remote
322
+ MCP client. `fetch-binary-file` treats this as its primary risk:
323
+
324
+ - Only `http`/`https` URLs are accepted.
325
+ - The hostname is resolved and the request is refused if any resolved address is
326
+ loopback, link-local, unique-local, or in RFC1918 private space.
327
+ - Every redirect hop is re-validated the same way — not just the initial URL — since
328
+ a public hostname can redirect to a private address.
329
+ - The response body is size-checked while streaming, so an oversized response is
330
+ aborted mid-transfer rather than after it's already been downloaded.
331
+ - A hard timeout (`fetchTimeoutMs`, overridable per call) aborts a slow or hanging
332
+ response.
333
+ - The destination path is deny-path- and collision-checked *before* any network
334
+ call, so a denied or already-occupied path never causes an outbound request.
335
+
336
+ `maxBytes` and `timeoutMs` can be overridden per call; otherwise they default to the
337
+ config file's `fetchMaxBytes`/`fetchTimeoutMs`.
338
+
339
+ ---
340
+
341
+ ## Connecting Claude Code
342
+
343
+ Add the server to your Claude Code config (`~/.claude.json` or via `claude mcp add`):
344
+
345
+ ```json
346
+ {
347
+ "mcpServers": {
348
+ "obsidian": {
349
+ "type": "http",
350
+ "url": "https://your-hostname:4001/mcp"
351
+ }
352
+ }
353
+ }
354
+ ```
355
+
356
+ Claude Code will prompt you to authenticate the first time — click through the
357
+ OAuth flow (it uses a public/no-credentials token, so no real account is needed).
358
+
359
+ ---
360
+
361
+ ## Connecting Claude Desktop
362
+
363
+ Unlike Claude Code, Claude Desktop doesn't speak the MCP Streamable HTTP transport
364
+ directly — it only launches local stdio subprocesses. To reach a remote server like
365
+ this one, it needs a small relay in between that translates its stdio JSON-RPC
366
+ traffic into HTTP+SSE calls against the server's `/mcp` endpoint. Two options:
367
+
368
+ - **[`mcp-remote`](https://www.npmjs.com/package/mcp-remote) via `npx`** (below) —
369
+ the quickest option, no install or local files needed, good for a plain
370
+ no-credentials setup like this server's public OAuth flow.
371
+ - **[`mcp-shim`](https://github.com/a1ecbr0wn/obsidian-mcp-brain#mcp-shim)** —
372
+ a zero-dependency local script from this project's repo, worth using instead if
373
+ you need a bearer token, a custom request timeout, or want to avoid an `npx`
374
+ download on every Claude Desktop launch.
375
+
376
+ Edit `claude_desktop_config.json` (find it via **Claude Desktop → Settings →
377
+ Developer → Edit Config**) and add an entry under `mcpServers`, replacing the URL
378
+ below with your server's actual address:
379
+
380
+ ```json
381
+ {
382
+ "mcpServers": {
383
+ "obsidian": {
384
+ "command": "npx",
385
+ "args": [
386
+ "mcp-remote@latest",
387
+ "https://your-hostname:4001/mcp"
388
+ ]
389
+ }
390
+ }
391
+ }
392
+ ```
393
+
394
+ Restart Claude Desktop after saving.
395
+
396
+ ---
397
+
398
+ ## Available Tools
399
+
400
+ | Tool | Description |
401
+ | --- | --- |
402
+ | `list-notes` | List all notes in the vault, or scoped to a folder. Returns sorted vault-relative paths, each with a last-modified timestamp |
403
+ | `list-tags` | List all unique tags (YAML frontmatter) used across vault notes, optionally scoped to a subdirectory |
404
+ | `search-tags` | Find notes that have ALL of the specified tags |
405
+ | `new-notes` | List notes created in the last 7 days, or since a provided timestamp |
406
+ | `changed-notes` | List notes modified in the last 7 days, or since a provided timestamp |
407
+ | `list-vaults` | List all configured vaults |
408
+ | `read-note` | Read a note's content and last-modified timestamp |
409
+ | `create-note` | Create a new note. Fails if it already exists |
410
+ | `edit-note` | Edit a note: append, prepend, replace, replace/delete the content under a heading, or toggle a checkbox |
411
+ | `delete-note` | Delete a note, moving it to `.trash` by default |
412
+ | `move-note` | Move or rename a note, rewriting all vault-wide wikilinks to the old path |
413
+ | `create-binary-file` | Create a new binary file (e.g. an image) from base64-encoded content. Fails if it already exists |
414
+ | `fetch-binary-file` | Create a new binary file by downloading a URL server-side, so the client only sends a URL, not the file content |
415
+ | `move-binary-file` | Move or rename a binary file, rewriting all vault-wide wikilink embeds pointing at the old path |
416
+ | `delete-binary-file` | Delete a binary file, moving it to `.trash` by default |
417
+ | `find-backlinks` | Find all notes that link to or embed a given note or binary file |
418
+ | `resolve-wikilink` | Resolve a wikilink target string to the vault-relative file(s) it points to |
419
+ | `create-folder` | Create a new folder (and any missing parents) in the vault |
420
+ | `search-vault` | Search vault notes by content, filename, or both |
421
+ | `add-tags` | Add tags to notes in frontmatter and/or inline body content |
422
+ | `remove-tags` | Remove tags from notes in frontmatter and/or inline body content |
423
+ | `rename-tag` | Rename a tag throughout the entire vault (frontmatter and inline) |
424
+ | `set-frontmatter-field` | Set a single frontmatter field (not `tags`) to a scalar value, creating it if missing |
425
+ | `remove-frontmatter-field` | Remove a single frontmatter field (not `tags`) entirely |
426
+ | `query-graph` | Ask a natural-language question against a vault's graphify knowledge graph |
427
+
428
+ ---
429
+
430
+ ## How obsidian-mcp-brain works
431
+
432
+ The server implements the [MCP Streamable HTTP transport (2024-11-05)](https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/transports/#streamable-http):
433
+
434
+ - **`POST /mcp`** — receives JSON-RPC requests from the client. `initialize` creates
435
+ a session and returns server capabilities. Notifications return 202 code. All other
436
+ requests are dispatched to the corresponding tool handler and the response is
437
+ returned as an inline SSE event.
438
+
439
+ - **`GET /mcp`** — keeps a long-lived SSE stream open per session for server-to-client
440
+ notifications (e.g. `tools/list_changed`).
441
+
442
+ All MCP tools are implemented natively in the server and operate directly on vault files
443
+ via `node:fs/promises`. Each tool resolves its `vault` argument against the configured
444
+ vaults and validates access control (that vault's effective deny list) before executing.
445
+
446
+ `resources/list` and `prompts/list` return empty results — the server does not expose
447
+ vault files as resources or prompts, only as tools.
448
+
449
+ ---
450
+
451
+ ## Security
452
+
453
+ The OAuth flow is intentionally public — there are no real credentials. Access control
454
+ relies on the network layer (Tailscale node authentication in the reference setup).
455
+ The config file's `denyPaths` feature provides coarse-grained control over which
456
+ parts of a vault the MCP client can touch, but it is not a substitute for
457
+ network-level access control.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obsidian-mcp-brain",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server exposing Obsidian vaults to remote clients (Claude Code, Claude Desktop) with OAuth discovery and path-level access control",
5
5
  "type": "module",
6
6
  "main": "obsidian-mcp-brain.mjs",
@@ -19,10 +19,11 @@
19
19
  },
20
20
  "keywords": [
21
21
  "mcp",
22
+ "mcp-server",
23
+ "model-context-protocol",
22
24
  "obsidian",
23
- "claude",
24
- "server",
25
- "oauth"
25
+ "obsidian-vault",
26
+ "obsidian-brain"
26
27
  ],
27
28
  "license": "Apache-2.0",
28
29
  "author": "a1ecbr0wn",