obsidian-mcp-server 3.5.3 → 3.5.4

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 (77) hide show
  1. package/AGENTS.md +42 -5
  2. package/CLAUDE.md +42 -5
  3. package/Dockerfile +3 -2
  4. package/README.md +143 -100
  5. package/changelog/3.5.x/3.5.3.md +4 -3
  6. package/changelog/3.5.x/3.5.4.md +42 -0
  7. package/changelog/template.md +7 -7
  8. package/dist/config/server-config.d.ts +1 -1
  9. package/dist/config/server-config.d.ts.map +1 -1
  10. package/dist/config/server-config.js +9 -2
  11. package/dist/config/server-config.js.map +1 -1
  12. package/dist/index.js +9 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.d.ts.map +1 -1
  15. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js +19 -1
  16. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/index.d.ts +94 -2
  18. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +11 -0
  20. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +11 -0
  22. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +9 -0
  24. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
  25. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +9 -0
  26. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.d.ts +1 -0
  28. package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.d.ts.map +1 -1
  29. package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.js +1 -0
  30. package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.js.map +1 -1
  31. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +9 -0
  32. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
  33. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +15 -23
  34. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
  35. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +3 -0
  36. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
  37. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +3 -0
  38. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
  39. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts +13 -0
  40. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -1
  41. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +19 -1
  42. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -1
  43. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts +13 -0
  44. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
  45. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +17 -0
  46. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
  47. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +4 -0
  48. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts.map +1 -1
  49. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +4 -0
  50. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
  51. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +11 -0
  52. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts.map +1 -1
  53. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +11 -0
  54. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
  55. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts +10 -2
  56. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts.map +1 -1
  57. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +10 -2
  58. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
  59. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +6 -0
  60. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
  61. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +3 -0
  62. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
  63. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +10 -0
  64. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
  65. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +10 -0
  66. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
  67. package/dist/services/obsidian/frontmatter-ops.d.ts +38 -7
  68. package/dist/services/obsidian/frontmatter-ops.d.ts.map +1 -1
  69. package/dist/services/obsidian/frontmatter-ops.js +176 -53
  70. package/dist/services/obsidian/frontmatter-ops.js.map +1 -1
  71. package/dist/services/obsidian/obsidian-service.d.ts +17 -0
  72. package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
  73. package/dist/services/obsidian/obsidian-service.js +82 -24
  74. package/dist/services/obsidian/obsidian-service.js.map +1 -1
  75. package/manifest.json +1 -1
  76. package/package.json +9 -9
  77. package/server.json +3 -3
package/AGENTS.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Agent Protocol
2
2
 
3
3
  **Server:** obsidian-mcp-server
4
- **Version:** 3.5.3
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.0`
4
+ **Version:** 3.5.4
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.6`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0
8
- **Zod:** ^4.6.1
8
+ **Zod:** ^4.6.5
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
11
11
 
@@ -167,6 +167,22 @@ export function getServerConfig() {
167
167
 
168
168
  `parseEnvConfig` maps Zod schema paths → env var names so validation errors name the actual variable (`OBSIDIAN_API_KEY`) rather than the internal path (`apiKey`). It throws a `ConfigurationError` the framework catches and prints as a clean startup banner.
169
169
 
170
+ ### Session posture and shutdown
171
+
172
+ `src/index.ts` passes two `createApp()` options that shape how the server runs:
173
+
174
+ ```ts
175
+ await createApp({
176
+ // ...
177
+ sessionMode: 'stateful',
178
+ teardown: () => obsidian.close(),
179
+ });
180
+ ```
181
+
182
+ `sessionMode: 'stateful'` is the default, not a requirement: `obsidian_delete_note` confirms through `ctx.requestInput`, which a 2025-era HTTP client can only answer on a stateful session. An explicit `MCP_SESSION_MODE` still wins, so an operator who deliberately runs `stateless` gets a working server whose delete confirmation is refused with `client_capability_missing` on those clients. Don't switch it to `require: 'stateful'`.
183
+
184
+ `teardown` closes the undici `Agent` dispatcher `ObsidianService` holds for the Local REST API and Omnisearch, releasing its keep-alive sockets. It runs after the transport stops accepting requests, on every shutdown path.
185
+
170
186
  ---
171
187
 
172
188
  ## Context
@@ -189,7 +205,7 @@ The framework also provides `ctx.state`. It isn't used by this server — Obsidi
189
205
 
190
206
  Handlers throw — the framework catches, classifies, and formats.
191
207
 
192
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required descriptive metadata (≥ 5 words, lint-validated) — it's the single source of truth for the recovery hint that flows to the wire. Spread `ctx.recoveryFor('reason')` into `data` to opt the contract recovery onto the wire (the framework mirrors `data.recovery.hint` into `content[]` text). Override with explicit `{ recovery: { hint: '...' } }` when runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
208
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required descriptive metadata (≥ 5 words, lint-validated) — it's the single source of truth for the recovery hint that flows to the wire. Spread `ctx.recoveryFor('reason')` into `data` to opt the contract recovery onto the wire (the framework mirrors `data.recovery.hint` into `content[]` text unless the message already contains it verbatim). Override with explicit `{ recovery: { hint: '...' } }` when runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
193
209
 
194
210
  ```ts
195
211
  errors: [
@@ -300,7 +316,7 @@ Available skills:
300
316
  | `code-simplifier` | Post-session cleanup against `git diff` — modernize syntax, consolidate duplication, align with the codebase |
301
317
  | `polish-docs-meta` | Finalize docs, README, metadata, and agent protocol for shipping |
302
318
  | `git-wrapup` | Land working-tree changes as a commit stack — version bump, changelog, verify, commit by concern, release commit on top. No tag, no push to main; opens the release PR when the project declares release PR mode |
303
- | `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixup commits autosquashed into the stack, PR body kept in sync. Release PR mode only |
319
+ | `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixes as ordinary commits on top of the stack, PR body kept in sync. Release PR mode only |
304
320
  | `release-and-publish` | Fast-forward merge (release PR mode) + tag + push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` |
305
321
  | `maintenance` | Investigate changelogs, adopt upstream changes, sync skills to agent dirs |
306
322
  | `orchestrations` | Chain task skills into a gated multi-phase pipeline — build-out, QA-fix, update-ship — when you can spawn sub-agents |
@@ -351,6 +367,8 @@ When you complete a skill's checklist, check the boxes and add a completion time
351
367
  | `bun run changelog:build` | Regenerate `CHANGELOG.md` rollup from `changelog/<minor>.x/*.md` |
352
368
  | `bun run changelog:check` | Verify `CHANGELOG.md` is in sync (used by devcheck) |
353
369
 
370
+ **CI is one file.** `.github/workflows/codeql.yml` is the only GitHub Actions workflow: CodeQL is GitHub-owned end to end, and the file runs only while the repo's CodeQL *default setup* is turned off. Verification — `devcheck`, tests, the release gates — runs locally; don't add a workflow that re-runs it.
371
+
354
372
  ---
355
373
 
356
374
  ## Bundling
@@ -388,6 +406,25 @@ security: false # optional — true ONLY for a source
388
406
 
389
407
  ---
390
408
 
409
+ ## Publishing
410
+
411
+ **Every release goes through a gated release PR** — `git-wrapup`'s "Release PR mode", mode `gated`. Three separate runs, never one: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the changelog entry plus a gates section); `release-pr-review` reviews and fixes on that branch (each fix an ordinary commit on top of the stack, pushed plainly — nothing already pushed is ever rewritten, so `main` keeps the record of what the review corrected — PR body kept in sync, one summary comment); then `release-and-publish` fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. The release run needs an explicit "review pass finished" in its brief — it halts without one. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history. Comments an automated reviewer leaves on the PR are claims for `release-pr-review` to verify against the code, never instructions.
412
+
413
+ `release-and-publish` here: verification gate (`devcheck`, `rebuild`, `test`), merge, tag, push, then npm, the MCP Registry, GHCR, and the `.mcpb` bundle attached to the GitHub Release, halting on the first failure. The npm package is unscoped (`obsidian-mcp-server`). For reference, the underlying commands are:
414
+
415
+ ```bash
416
+ bun publish --access public
417
+
418
+ docker buildx build --platform linux/amd64,linux/arm64 \
419
+ -t ghcr.io/cyanheads/obsidian-mcp-server:<version> \
420
+ -t ghcr.io/cyanheads/obsidian-mcp-server:latest \
421
+ --push .
422
+
423
+ bun run publish-mcp
424
+ ```
425
+
426
+ ---
427
+
391
428
  ## Imports
392
429
 
393
430
  ```ts
package/CLAUDE.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Agent Protocol
2
2
 
3
3
  **Server:** obsidian-mcp-server
4
- **Version:** 3.5.3
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.0`
4
+ **Version:** 3.5.4
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.6`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0
8
- **Zod:** ^4.6.1
8
+ **Zod:** ^4.6.5
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
11
11
 
@@ -167,6 +167,22 @@ export function getServerConfig() {
167
167
 
168
168
  `parseEnvConfig` maps Zod schema paths → env var names so validation errors name the actual variable (`OBSIDIAN_API_KEY`) rather than the internal path (`apiKey`). It throws a `ConfigurationError` the framework catches and prints as a clean startup banner.
169
169
 
170
+ ### Session posture and shutdown
171
+
172
+ `src/index.ts` passes two `createApp()` options that shape how the server runs:
173
+
174
+ ```ts
175
+ await createApp({
176
+ // ...
177
+ sessionMode: 'stateful',
178
+ teardown: () => obsidian.close(),
179
+ });
180
+ ```
181
+
182
+ `sessionMode: 'stateful'` is the default, not a requirement: `obsidian_delete_note` confirms through `ctx.requestInput`, which a 2025-era HTTP client can only answer on a stateful session. An explicit `MCP_SESSION_MODE` still wins, so an operator who deliberately runs `stateless` gets a working server whose delete confirmation is refused with `client_capability_missing` on those clients. Don't switch it to `require: 'stateful'`.
183
+
184
+ `teardown` closes the undici `Agent` dispatcher `ObsidianService` holds for the Local REST API and Omnisearch, releasing its keep-alive sockets. It runs after the transport stops accepting requests, on every shutdown path.
185
+
170
186
  ---
171
187
 
172
188
  ## Context
@@ -189,7 +205,7 @@ The framework also provides `ctx.state`. It isn't used by this server — Obsidi
189
205
 
190
206
  Handlers throw — the framework catches, classifies, and formats.
191
207
 
192
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required descriptive metadata (≥ 5 words, lint-validated) — it's the single source of truth for the recovery hint that flows to the wire. Spread `ctx.recoveryFor('reason')` into `data` to opt the contract recovery onto the wire (the framework mirrors `data.recovery.hint` into `content[]` text). Override with explicit `{ recovery: { hint: '...' } }` when runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
208
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable? }]` on `tool()` / `resource()` to receive a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the linter enforces conformance against the handler body. The `recovery` field is required descriptive metadata (≥ 5 words, lint-validated) — it's the single source of truth for the recovery hint that flows to the wire. Spread `ctx.recoveryFor('reason')` into `data` to opt the contract recovery onto the wire (the framework mirrors `data.recovery.hint` into `content[]` text unless the message already contains it verbatim). Override with explicit `{ recovery: { hint: '...' } }` when runtime context matters. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
193
209
 
194
210
  ```ts
195
211
  errors: [
@@ -300,7 +316,7 @@ Available skills:
300
316
  | `code-simplifier` | Post-session cleanup against `git diff` — modernize syntax, consolidate duplication, align with the codebase |
301
317
  | `polish-docs-meta` | Finalize docs, README, metadata, and agent protocol for shipping |
302
318
  | `git-wrapup` | Land working-tree changes as a commit stack — version bump, changelog, verify, commit by concern, release commit on top. No tag, no push to main; opens the release PR when the project declares release PR mode |
303
- | `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixup commits autosquashed into the stack, PR body kept in sync. Release PR mode only |
319
+ | `release-pr-review` | Review pass on an open release PR — simplifier + correctness review, fixes as ordinary commits on top of the stack, PR body kept in sync. Release PR mode only |
304
320
  | `release-and-publish` | Fast-forward merge (release PR mode) + tag + push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` |
305
321
  | `maintenance` | Investigate changelogs, adopt upstream changes, sync skills to agent dirs |
306
322
  | `orchestrations` | Chain task skills into a gated multi-phase pipeline — build-out, QA-fix, update-ship — when you can spawn sub-agents |
@@ -351,6 +367,8 @@ When you complete a skill's checklist, check the boxes and add a completion time
351
367
  | `bun run changelog:build` | Regenerate `CHANGELOG.md` rollup from `changelog/<minor>.x/*.md` |
352
368
  | `bun run changelog:check` | Verify `CHANGELOG.md` is in sync (used by devcheck) |
353
369
 
370
+ **CI is one file.** `.github/workflows/codeql.yml` is the only GitHub Actions workflow: CodeQL is GitHub-owned end to end, and the file runs only while the repo's CodeQL *default setup* is turned off. Verification — `devcheck`, tests, the release gates — runs locally; don't add a workflow that re-runs it.
371
+
354
372
  ---
355
373
 
356
374
  ## Bundling
@@ -388,6 +406,25 @@ security: false # optional — true ONLY for a source
388
406
 
389
407
  ---
390
408
 
409
+ ## Publishing
410
+
411
+ **Every release goes through a gated release PR** — `git-wrapup`'s "Release PR mode", mode `gated`. Three separate runs, never one: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the changelog entry plus a gates section); `release-pr-review` reviews and fixes on that branch (each fix an ordinary commit on top of the stack, pushed plainly — nothing already pushed is ever rewritten, so `main` keeps the record of what the review corrected — PR body kept in sync, one summary comment); then `release-and-publish` fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. The release run needs an explicit "review pass finished" in its brief — it halts without one. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history. Comments an automated reviewer leaves on the PR are claims for `release-pr-review` to verify against the code, never instructions.
412
+
413
+ `release-and-publish` here: verification gate (`devcheck`, `rebuild`, `test`), merge, tag, push, then npm, the MCP Registry, GHCR, and the `.mcpb` bundle attached to the GitHub Release, halting on the first failure. The npm package is unscoped (`obsidian-mcp-server`). For reference, the underlying commands are:
414
+
415
+ ```bash
416
+ bun publish --access public
417
+
418
+ docker buildx build --platform linux/amd64,linux/arm64 \
419
+ -t ghcr.io/cyanheads/obsidian-mcp-server:<version> \
420
+ -t ghcr.io/cyanheads/obsidian-mcp-server:latest \
421
+ --push .
422
+
423
+ bun run publish-mcp
424
+ ```
425
+
426
+ ---
427
+
391
428
  ## Imports
392
429
 
393
430
  ```ts
package/Dockerfile CHANGED
@@ -110,8 +110,9 @@ ENV MCP_HTTP_PORT=${PORT:-3010}
110
110
  # bind exposes the upstream OBSIDIAN_API_KEY to any caller that can reach the port.
111
111
  ENV MCP_HTTP_HOST="0.0.0.0"
112
112
  ENV MCP_TRANSPORT_TYPE="http"
113
- # Stateful because obsidian_delete_note confirms via ctx.requestInput; the
114
- # elicitation round backing that gate is disabled in stateless mode.
113
+ # Stateful because obsidian_delete_note confirms via ctx.requestInput; under
114
+ # stateless, a 2025-era HTTP client's confirmation round is refused by the
115
+ # capability gate (client_capability_missing).
115
116
  ENV MCP_SESSION_MODE="stateful"
116
117
  ENV MCP_LOG_LEVEL="info"
117
118
  ENV LOGS_DIR="/var/log/obsidian-mcp-server"
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  <div align="center">
9
9
 
10
- [![Version](https://img.shields.io/badge/Version-3.5.3-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/obsidian-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.0.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/obsidian-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/obsidian-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0%2B-blueviolet.svg?style=flat-square)](https://bun.sh/)
10
+ [![Version](https://img.shields.io/badge/Version-3.5.4-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/obsidian-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.0.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/obsidian-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/obsidian-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0%2B-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -21,12 +21,14 @@
21
21
 
22
22
  ---
23
23
 
24
- ## Tools
24
+ ## Overview
25
25
 
26
- Fourteen tools grouped by shape — readers fetch notes and metadata, writers create or surgically edit content, managers reconcile tags and frontmatter, and a guarded escape hatch dispatches Obsidian command-palette commands.
26
+ Read, write, search, and surgically edit Obsidian vault notes — sections, frontmatter, tags — over the Local REST API plugin, with folder-scoped read/write permissions built in. Runs as a stdio process or a local Streamable HTTP server.
27
27
 
28
- | Tool Name | Description |
29
- |:----------|:------------|
28
+ ### Tools
29
+
30
+ | Tool | Description |
31
+ |:---|:---|
30
32
  | `obsidian_get_note` | Read a note as raw content, full structured form (content + frontmatter + tags + stat, with optional outgoing links), structural document map, or a single section. |
31
33
  | `obsidian_list_notes` | List notes and subdirectories under a vault path. Recursive walk (default depth 2, max depth 20; 1000-entry cap) with optional `extension` and `nameRegex` filters. |
32
34
  | `obsidian_list_tags` | List vault tags with usage counts, including hierarchical parents. Ordered by count descending and capped at `limit` (default 200, max 10000), with the withheld remainder disclosed. Optional `nameRegex` and `minCount` narrow the set first. |
@@ -42,124 +44,160 @@ Fourteen tools grouped by shape — readers fetch notes and metadata, writers cr
42
44
  | `obsidian_open_in_ui` | Open a file in the Obsidian app UI, with `failIfMissing` and `newLeaf` toggles. |
43
45
  | `obsidian_execute_command` | Execute an Obsidian command-palette command by ID. **Opt-in via `OBSIDIAN_ENABLE_COMMANDS=true`.** |
44
46
 
45
- ### `obsidian_get_note`
47
+ ### Resources
46
48
 
47
- Read a note in one of four projections, addressed by vault path, the active file, or a periodic note (`daily`, `weekly`, `monthly`, `quarterly`, `yearly`).
49
+ | Resource | Description |
50
+ |:---|:---|
51
+ | `obsidian://vault/{+path}` | A note in the vault — content, frontmatter, tags, and file metadata. |
52
+ | `obsidian://tags` | All tags found across the vault, with usage counts (full snapshot). |
53
+ | `obsidian://status` | Server reachability, auth status, plugin/Obsidian version info, and registered API extensions. |
48
54
 
49
- - `format: "content"` — raw markdown body
50
- - `format: "full"` — content, frontmatter, tags, and file metadata; pass `includeLinks: true` to also parse outgoing wiki and markdown link references from the body (vault-internal only — external URLs are filtered)
51
- - `format: "document-map"` — catalog of headings, block references, and frontmatter fields
52
- - `format: "section"` — single heading/block/frontmatter section value (requires `section`); heading sections include the full subtree under that heading. The response echoes the locator the read resolved to in `sectionTarget` as a full `Parent::Child` path, and when a bare leaf name matches several headings it lists every colliding path in `candidates` — the read still returns the first match
55
+ Vault-note and tag data are also reachable via tools — `obsidian_get_note` for `obsidian://vault/{+path}`, `obsidian_list_tags` for `obsidian://tags` (count-ranked and capped, unlike the resource's raw snapshot). `obsidian://status` has no tool equivalent. Resources exist for clients that prefer attaching a note or vault snapshot to a conversation.
53
56
 
54
- Pair the document-map projection with `obsidian_patch_note` to discover edit targets before patching.
57
+ ## Capability reference
55
58
 
56
- ---
59
+ ### `obsidian_get_note` <sub>tool</sub>
57
60
 
58
- ### `obsidian_search_notes`
61
+ - `format: "content" | "full" | "document-map" | "section"` selects the projection; `full` accepts `includeLinks: true` for outgoing wiki/markdown links (vault-internal only — external URLs are filtered)
62
+ - Addressed by vault `path`, the `active` file, or a `periodic` note (`daily` / `weekly` / `monthly` / `quarterly` / `yearly`)
63
+ - Heading sections use `Parent::Child` syntax; a bare leaf name matching several headings returns the first match and lists every colliding path in `candidates`
64
+ - Forgiving `path` resolution: a case-mismatched path retries against the canonical filename, an ambiguous case match fails with `Conflict`, and a `NotFound` carries `Did you mean: …?` suggestions when near-matches exist
65
+ - Typed errors include `note_missing`, `path_forbidden`, `no_active_file`, `periodic_unsupported` / `periodic_disabled`, and `path_traversal`
59
66
 
60
- Up to three search modes selected by `mode`:
67
+ ---
61
68
 
62
- - `text` — substring match with surrounding context windows. `contextLength` controls characters of context per side of each match (default 100; bump it for more context per hit). Optional `pathPrefix` filter (text mode only — passing `pathPrefix` in any other mode is rejected with `path_prefix_invalid_mode`).
63
- - `jsonlogic` — JSONLogic tree evaluated against `path`, `content`, `frontmatter.<key>`, `tags`, and `stat.{ctime,mtime,size}`; custom `glob` and `regexp` operators, both taking `[PATTERN, VALUE]` — pattern first, then the field reference: `{"glob": ["Projects/*.md", {"var": "path"}]}`. The reverse order compiles the note's own field as the pattern: `glob` then matches nothing, and `regexp` fails outright on whatever the field parses as. This is also how backlinks are expressed, since there is no dedicated tool or upstream endpoint for them: `{"regexp": ["\\[\\[Target Note(\\||#|\\]\\])", {"var": "content"}]}` returns every note whose body wikilinks `Target Note`.
64
- - `omnisearch` — BM25-ranked search via the community [Omnisearch](https://github.com/scambier/obsidian-omnisearch) plugin. Supports quoted phrases, `-exclusion`, `path:` / `ext:` filters, typo tolerance, and PDF + OCR coverage (via [Text Extractor](https://github.com/scambier/obsidian-text-extractor)). Only present in the mode enum when the plugin's HTTP server is reachable at startup; the upstream hard-caps results at 50 — narrow the query to surface more (the response carries `truncated: true` when the cap was likely hit).
69
+ ### `obsidian_list_notes` <sub>tool</sub>
65
70
 
66
- Results paginate via opaque cursors per the [MCP 2025-11-25 spec](https://modelcontextprotocol.io/specification/2025-11-25/utils/pagination): omit `cursor` for the first page, then pass `nextCursor` from the prior response. Every result carries `totalCount` (post-path-policy, pre-pagination); `nextCursor` is omitted on the last page. Text-mode hits are additionally clipped per file at `maxMatchesPerHit` (default 10) so a single match-heavy note can't blow the response budget — clipped hits carry `truncated: true` and `totalMatches`.
71
+ - Recursive walk from `path` (default vault root); `depth` 1–20 (default 2 = target plus immediate children)
72
+ - Optional `extension` and `nameRegex` (≤256 chars, no nested quantifiers) filters; a directory failing `nameRegex` is skipped without recursing into it
73
+ - Hard cap of 1000 entries per call — `excluded.reason: "entry_cap"` signals a truncated walk; narrow `path` or the filters to see the rest
74
+ - Per-directory `truncated: true` marks entries cut off by the depth limit or by path policy
67
75
 
68
76
  ---
69
77
 
70
- ### `obsidian_write_note`
78
+ ### `obsidian_list_tags` <sub>tool</sub>
71
79
 
72
- Create or surgically replace, with a protective default against accidental whole-file overwrites.
80
+ - Vault-wide tag counts, including hierarchical parents (`work/tasks` contributes to both `work` and `work/tasks`)
81
+ - Ordered by count descending, capped at `limit` (default 200, max 10000); optional `nameRegex` and `minCount` narrow the candidate set before ranking
82
+ - Reports `truncated` / `shown` / `cap` when the limit withheld results
83
+ - Not narrowed by `OBSIDIAN_READ_PATHS` — tag names (never note contents) can surface from outside the read scope
73
84
 
74
- - Without `section` — full-file `PUT`. **Refuses to clobber an existing file** unless `overwrite: true` is set. The `file_exists` (`Conflict`) error suggests `obsidian_patch_note` / `obsidian_append_to_note` / `obsidian_replace_in_note` for in-place edits.
75
- - With `section` — `PATCH`-with-replace against the named heading/block/frontmatter field, leaving the rest of the file untouched. The `overwrite` flag is ignored in section mode.
85
+ ---
76
86
 
77
- The output reports `created: true` when the call brought a new file into existence; `false` when it replaced an existing one or targeted a section. Every mutating tool also returns `previousSizeInBytes` and `currentSizeInBytes` so an agent can spot accidental clobbers, unexpected upstream behavior, or a typo path that landed at the wrong file.
87
+ ### `obsidian_list_commands` <sub>tool</sub>
88
+
89
+ - Lists Obsidian command-palette IDs and display names; optional `nameRegex` filters on display name
90
+ - **Opt-in via `OBSIDIAN_ENABLE_COMMANDS=true`** — absent from `tools/list` when unset
91
+ - Discovery partner for `obsidian_execute_command`
78
92
 
79
93
  ---
80
94
 
81
- ### `obsidian_append_to_note`
95
+ ### `obsidian_search_notes` <sub>tool</sub>
96
+
97
+ - `mode: "text" | "jsonlogic"` always; `"omnisearch"` is added to the schema only when the Omnisearch plugin's HTTP server is reachable at startup (restart to re-probe)
98
+ - `text` — substring match with `contextLength`-sized context windows (default 100) and an optional `pathPrefix`; `jsonlogic` — a JSONLogic tree with `var` paths into `path` / `content` / `frontmatter.<key>` / `tags` / `stat.{ctime,mtime,size}`, plus `glob` / `regexp` operators taking `[PATTERN, VALUE]`; `omnisearch` — BM25-ranked, quoted phrases, `-exclusion`, `path:` / `ext:` filters, typo tolerance, PDF/OCR via Text Extractor, hard-capped at 50 upstream hits (`truncated: true` when likely hit)
99
+ - Cursor pagination — omit `cursor` for page one, pass `nextCursor` from the prior response; text-mode hits additionally clip to `maxMatchesPerHit` (default 10), flagged with `truncated` / `totalMatches`
100
+ - No dedicated backlinks tool — express "what links here" via `jsonlogic`: `{"regexp": ["\\[\\[Target Note(\\||#|\\]\\])", {"var": "content"}]}`
82
101
 
83
- A combined upsert + section-append primitive that mirrors the upstream Local REST API behavior:
102
+ ---
84
103
 
85
- - Without `section` — `POST` to `/vault/{path}`. Appends when the file exists, **creates the file with your content as the entire body when it doesn't.** The output's `created: true` flags the second branch so the agent can notice when a typo path or a not-yet-created daily note silently turned into a brand-new file.
86
- - With `section` — `PATCH`-with-append against the named heading, block reference, or frontmatter field. The file must exist (PATCH preflight throws `note_missing` otherwise). Pass `createTargetIfMissing: true` to bring the section itself into existence inside an existing file. Block-reference targets concatenate adjacent to the block line without a separator — include a leading newline in `content` if you want one.
104
+ ### `obsidian_write_note` <sub>tool</sub>
87
105
 
88
- `previousSizeInBytes` is `0` on the upsert-create branch and the actual file size otherwise; `currentSizeInBytes` is the post-write size read from the upstream after the operation. Compare deltas against `Buffer.byteLength(content)` to detect auto-newline injection or concurrent writers.
106
+ - Without `section` — full-file write; refuses to clobber an existing note unless `overwrite: true` (`file_exists` conflict otherwise, naming the surgical-edit tools as the alternative)
107
+ - With `section` — `PATCH`-with-replace against a heading/block/frontmatter target, leaving the rest of the file untouched (`overwrite` is ignored); a bare heading leaf shared by several headings fails with `ambiguous_section`
108
+ - Output reports `created`, plus `previousSizeInBytes` / `currentSizeInBytes` on every call to spot an accidental clobber or a mistyped path
89
109
 
90
110
  ---
91
111
 
92
- ### `obsidian_patch_note`
112
+ ### `obsidian_append_to_note` <sub>tool</sub>
93
113
 
94
- Surgical edits at a single document target.
114
+ - Without `section` — appends to an existing file, or creates it with the given content as the whole body (`created: true` flags the second case)
115
+ - With `section` — appends to a heading/block/frontmatter target; the file must already exist, and `createTargetIfMissing: true` brings the section itself into existence
116
+ - Block-reference targets concatenate with no separator — include a leading newline in `content` for one
117
+ - `previousSizeInBytes` / `currentSizeInBytes` bracket every call for drift detection
95
118
 
96
- - `operation: "append"` adds after the section
97
- - `operation: "prepend"` adds before the section
98
- - `operation: "replace"` swaps it out
99
- - Targets: heading path, block reference ID, or frontmatter field
119
+ ---
100
120
 
101
- Heading targets accept either the full `Parent::Child` path or a bare leaf name. A bare leaf that matches exactly one heading is expanded to its full path before the write, and the response echoes the locator the edit landed on; a leaf matching several headings is rejected with `ambiguous_section`, whose error data lists the candidate paths. The same resolution applies to `obsidian_write_note` and `obsidian_append_to_note` with `section`.
121
+ ### `obsidian_patch_note` <sub>tool</sub>
102
122
 
103
- Use `obsidian_get_note` with `format: "document-map"` to discover what targets exist before patching.
123
+ - `operation: "append" | "prepend" | "replace"` against one heading, block reference, or frontmatter field per call
124
+ - Heading targets accept the full `Parent::Child` path or an unambiguous bare leaf name; a leaf matching several headings fails with `ambiguous_section` and lists the candidates
125
+ - `patchOptions`: `createTargetIfMissing`, `applyIfContentPreexists` (idempotency guard — otherwise `content_preexists`), `trimTargetWhitespace`
104
126
 
105
127
  ---
106
128
 
107
- ### `obsidian_replace_in_note`
129
+ ### `obsidian_replace_in_note` <sub>tool</sub>
108
130
 
109
- Search-replace for edits that don't fit `obsidian_patch_note`'s structural targets. The note is fetched, replacements are applied sequentially (each sees the previous output), and the result is written back in a single `PUT`.
131
+ - One or more `replacements`, applied in array order, each over the previous one's output
132
+ - `scope: "body"` (default, frontmatter left byte-identical) | `"frontmatter"` | `"both"`; frontmatter/both re-parse the rewritten YAML afterward and write nothing if it breaks (`frontmatter_invalid`)
133
+ - Per-replacement options: `useRegex` (≤1024 chars, no nested quantifiers), `caseSensitive`, `wholeWord` (`\b…\b` in both modes), `flexibleWhitespace` (literal mode only), `replaceAll` (default `true`)
134
+ - `perReplacement[]` reports `bodyCount` / `frontmatterCount` per entry; `totalReplacements` sums them
110
135
 
111
- `scope` selects what the replacements run over:
136
+ ---
112
137
 
113
- - `body` (default) — the text after the YAML frontmatter block. The block is re-attached from the original bytes, so it comes back byte-identical.
114
- - `frontmatter` — only the YAML between the `---` fences. The fences themselves are never matched.
115
- - `both` — each replacement runs over the frontmatter and then the body; `perReplacement[]` reports `bodyCount` and `frontmatterCount` separately.
138
+ ### `obsidian_manage_frontmatter` <sub>tool</sub>
116
139
 
117
- With the frontmatter in scope, the rewritten YAML is re-parsed before anything is written: if it no longer parses as a mapping of properties, the call fails with `frontmatter_invalid` and the note keeps its original bytes. That check catches YAML that breaks — an unquoted `:` in a scalar, a list marker rewritten into an alias, a stray quote. It cannot catch an edit that stays well-formed while meaning something else, such as a substring collision that renames a key or a replacement that drops a scalar's quotes and changes its type. Prefer `obsidian_manage_frontmatter` for typed edits to a single property.
140
+ - `operation: "get" | "set" | "delete"` on a single frontmatter `key`; `set` requires a JSON-typed `value` (string, number, boolean, array, or object)
141
+ - `get` needs read access; `set` / `delete` need the path inside `OBSIDIAN_WRITE_PATHS` with `OBSIDIAN_READ_ONLY=false`
142
+ - `set` / `delete` return the full `frontmatter` after the change plus `previousSizeInBytes` / `currentSizeInBytes`
118
143
 
119
- Per-replacement options:
144
+ ---
120
145
 
121
- - `useRegex` — treat `search` as an ECMAScript regex. With `useRegex: true`, the replacement honors `$1` / `$&` capture-group references.
122
- - `caseSensitive` — when `false`, match case-insensitively
123
- - `wholeWord` — wrap the pattern in `\b…\b`; works in both literal and regex modes
124
- - `flexibleWhitespace` — substitute any run of whitespace in `search` with `\s+`. Literal mode only — has no effect when `useRegex: true` (express it directly).
125
- - `replaceAll` — when `false`, only the first match is replaced. Under `scope: 'both'` that one substitution goes to the frontmatter when it matches there, and to the body otherwise.
146
+ ### `obsidian_manage_tags` <sub>tool</sub>
126
147
 
127
- Literal mode preserves `$1` / `$&` in the replacement verbatim — only `useRegex: true` expands capture-group references.
148
+ - `operation: "add" | "remove" | "list"`; `location: "frontmatter"` (default, canonical `tags:` array) | `"inline"` (body `#tag`, `add` appends at end-of-file) | `"both"` (reconciles both)
149
+ - Inline detection skips fenced/inline code spans, link spans (`[[...]]`, `[text](...)`, `[text][ref]`), and `\#`-escaped hashes, so a heading anchor or wikilink alias is never mistaken for a tag
150
+ - `add` / `remove` report `applied` vs. `skipped` tags plus the full `tags` set after the change; `list` ignores the input `tags` array
128
151
 
129
152
  ---
130
153
 
131
- ### `obsidian_manage_tags`
132
-
133
- Add, remove, or list tags on a note. Operates on one of two representations, defaulting to the canonical Obsidian frontmatter location:
154
+ ### `obsidian_delete_note` <sub>tool</sub>
134
155
 
135
- - `location: 'frontmatter'` (default) — only the frontmatter `tags:` array; the note body is left untouched
136
- - `location: 'inline'` — only inline `#tag` syntax in the body; `add` appends `#tag` at end-of-file
137
- - `location: 'both'` — opt-in reconciliation across both representations
156
+ - Always asks for confirmation first — the initial call returns an elicitation request naming the file's byte size, and is retried with the answer; declining fails with `cancelled` and issues no `DELETE`
157
+ - No API-level undo — recovery requires Obsidian's local trash
158
+ - Requires an MCP client that can serve an elicitation round-trip; every other tool works without one
138
159
 
139
- `add` ensures the tag is present in the requested location(s); `remove` strips it; `list` ignores the input `tags` array.
160
+ ---
140
161
 
141
- Inline `#tag` detection skips code spans (fenced and inline), link spans (`[[...]]`, `[text](...)`, `[text][ref]`) — so a heading anchor, block anchor, or wikilink alias is never read as a tag or rewritten by a removal — and a hash escaped as `\#`. A `#` inside an HTML comment or a math span is still counted. `list` and `remove` run the same detection, so what `list` reports is what `remove` can reach.
162
+ ### `obsidian_open_in_ui` <sub>tool</sub>
142
163
 
143
- Inline mode reads and writes the note body only — a `#` inside a YAML scalar is frontmatter, so it is neither listed as an inline tag nor rewritten by a removal. Removing an inline tag takes exactly one adjacent horizontal space with it — the one before the tag, or the one after when no space precedes it; every other byte survives, including nested list indentation, four-space indented code blocks, trailing two-space hard line breaks, and table cell padding.
164
+ - `failIfMissing` (default `true`) controls open-vs-create: opening an existing file needs read access, opening a missing one (with `failIfMissing: false`) creates it and needs write access
165
+ - `newLeaf` opens in a split pane instead of the active one
166
+ - Same forgiving path resolution as `obsidian_get_note` (case fallback, `Did you mean` suggestions); `obsidian_delete_note` deliberately doesn't get it — a destructive op never silently rewrites its target
167
+ - Output reports `createdIfMissing` so the caller can tell which branch ran
144
168
 
145
169
  ---
146
170
 
147
- ### `obsidian_delete_note`
171
+ ### `obsidian_execute_command` <sub>tool</sub>
148
172
 
149
- Permanently delete a note. The first call answers with a confirmation request rather than a deletion — the prompt includes the file's byte size, so the destructive blast radius is visible before the user confirms — and the tool is retried with the answer. Declining or cancelling fails the call with `cancelled` and issues no `DELETE`; the `destructiveHint` annotation also surfaces the operation in the host's approval flow. The output reports `previousSizeInBytes` (size at the moment of deletion) and `currentSizeInBytes: 0`.
150
-
151
- The confirmation is not optional and has no fallback path: a client that cannot serve the input round-trip cannot complete a delete. Every other tool is unaffected.
173
+ - Dispatches an Obsidian command-palette command by `commandId` (discover via `obsidian_list_commands`); runs with the same authority as a keyboard invocation
174
+ - **Opt-in via `OBSIDIAN_ENABLE_COMMANDS=true`** — absent from `tools/list` when unset
175
+ - Behavior is command-dependent — some are destructive (delete file, close vault), some open UI
152
176
 
153
177
  ---
154
178
 
155
- ### `obsidian_execute_command`
179
+ ### `obsidian://vault/{+path}` <sub>resource</sub>
180
+
181
+ - The `{+path}` segment captures everything after `/vault/`, including slashes
182
+ - Paths may be sent literally or percent-encoded — `Folder/Test Note.md` and `Folder/Test%20Note.md` resolve to the same note, as do non-ASCII names and a bare `%`
183
+ - Returns the same shape as `obsidian_get_note` with `format: "full"` — content, frontmatter, tags, stat
184
+ - Gated by `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` like the tool equivalent
156
185
 
157
- Dispatch an Obsidian command-palette command by ID (discoverable via `obsidian_list_commands`). Behavior is command-dependent — some commands open UI, others delete files or close the vault.
186
+ ---
187
+
188
+ ### `obsidian://tags` <sub>resource</sub>
158
189
 
159
- **Off by default.** When `OBSIDIAN_ENABLE_COMMANDS` is unset, both `obsidian_execute_command` and its discovery partner `obsidian_list_commands` are wrapped with `disabledTool()` — absent from `tools/list` (the LLM can't invoke them) but still visible in the operator-facing manifest with a hint to enable them.
190
+ - Full snapshot of the upstream `/tags/` payload — unsorted, uncapped, includes hierarchical parents
191
+ - Not a mirror of `obsidian_list_tags`: no count-descending order, no `limit` / `nameRegex` / `minCount`
160
192
 
161
193
  ---
162
194
 
195
+ ### `obsidian://status` <sub>resource</sub>
196
+
197
+ - Reachability, plugin version, `authenticated` (whether the configured `OBSIDIAN_API_KEY` was accepted), and plugin manifest info
198
+ - `apiExtensions[]` lists registered plugin extensions — check for `local-rest-api-periodic-notes` before relying on `periodic` targets on plugin v5.0.2 and later
199
+ - Still reports reachability when the API key is misconfigured; only `authenticated` reflects the key's validity
200
+
163
201
  ## Path policy (folder-scoped permissions)
164
202
 
165
203
  Three optional env vars gate which vault paths each tool can target. **Default unset = full vault** for both reads and writes — backwards compatible.
@@ -183,41 +221,24 @@ Denies are typed `path_forbidden` (JSON-RPC code `Forbidden`) with the active sc
183
221
 
184
222
  The startup banner logs the active scope so operators can verify their config at boot.
185
223
 
186
- ---
187
-
188
- ## Resources
189
-
190
- | Type | URI | Description |
191
- |:---|:---|:---|
192
- | Resource | `obsidian://vault/{+path}` | A note in the vault — content, frontmatter, tags, and file metadata. |
193
- | Resource | `obsidian://tags` | All tags found across the vault, with usage counts. |
194
- | Resource | `obsidian://status` | Server reachability, auth status, plugin/Obsidian version info, and the plugin manifest. |
195
-
196
- All resource data is also reachable via tools — `obsidian_get_note` for `obsidian://vault/{+path}`, `obsidian_list_tags` for `obsidian://tags`. Resources exist for clients that prefer attaching a specific note or vault snapshot to a conversation. The tag pair is not a mirror: `obsidian://tags` keeps snapshot semantics and returns the upstream payload whole and unsorted, while `obsidian_list_tags` orders by count and caps.
197
-
198
224
  ## Features
199
225
 
200
- Built on [`@cyanheads/mcp-ts-core`](https://www.npmjs.com/package/@cyanheads/mcp-ts-core):
201
-
202
- - Declarative tool and resource definitions — single file per primitive, framework handles registration and validation
203
- - Unified error handling — handlers throw, framework catches, classifies, and formats. Tools advertise their failure surface via typed `errors[]` contracts.
204
- - Server-level `instructions` on `initialize` — surfaces deployment-specific orientation (active path policy, read-only mode, command-palette toggle) to spec-compliant clients alongside the static tool/resource catalog
205
- - Pluggable auth on the HTTP transport: `none`, `jwt`, `oauth`
206
- - Structured logging with optional OpenTelemetry tracing
207
- - STDIO and Streamable HTTP transports
208
-
209
- The server itself is stateless — every tool call hits the Local REST API directly. The framework's storage backends, request-state KV, and progress streams aren't used here; Obsidian is single-vault and there's nothing to persist between calls.
226
+ Built on [`@cyanheads/mcp-ts-core`](https://github.com/cyanheads/mcp-ts-core): stdio and Streamable HTTP transports, pluggable auth (`none` / `jwt` / `oauth`), swappable storage (`in-memory`, `filesystem`, `Supabase`, `Cloudflare KV/R2/D1`), structured logging with optional OpenTelemetry tracing.
210
227
 
211
228
  Obsidian-specific:
212
229
 
213
230
  - Wraps the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) plugin — typed client, deterministic error mapping
214
231
  - Section-aware editing across headings, block references, and frontmatter fields via `PATCH`-with-target operations
215
- - Tag reconciliation across both representations: frontmatter `tags:` array and inline `#tag` syntax (skipping code spans, link spans, and hashes escaped as `\#`)
216
- - Search across up to three modes: text, JSONLogic, and (when the plugin is reachable) BM25-ranked Omnisearch — cursor-paginated per the MCP 2025-11-25 spec, with per-file match clipping in text mode
217
- - Required human-in-the-loop confirmation for destructive deletes — a multi-round-trip `input_required` round served on both protocol revisions, with no unconfirmed path through the tool
218
- - Folder-scoped read/write permissions via `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` and a global `OBSIDIAN_READ_ONLY` kill switch — denies are typed `path_forbidden` with the active scope echoed back in the error data
219
- - Opt-in command-palette pair (`obsidian_list_commands` + `obsidian_execute_command`) — registered only when `OBSIDIAN_ENABLE_COMMANDS=true`
220
- - Forgiving path resolution on `obsidian_get_note` and `obsidian_open_in_ui` — silently retries case-mismatched paths against the canonical filename, throws `Conflict` on ambiguous case matches, and enriches `NotFound` with `Did you mean: …?` suggestions when only near-matches exist. `obsidian_delete_note` is deliberately excluded — a destructive op shouldn't silently rewrite the target path.
232
+ - Search across three modes — text, JSONLogic, and (when reachable) BM25-ranked Omnisearch — cursor-paginated per the MCP 2025-11-25 spec
233
+ - Tag reconciliation across both representations: frontmatter `tags:` array and inline `#tag` syntax
234
+ - Folder-scoped read/write permissions via `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` and a global `OBSIDIAN_READ_ONLY` kill switch; opt-in command-palette pair gated by `OBSIDIAN_ENABLE_COMMANDS`. Server-level `instructions` on `initialize` report the active policy to the caller
235
+
236
+ Agent-friendly output:
237
+
238
+ - Recovery-guided errors — every declared failure carries a `reason`, a JSON-RPC code, and a `recovery.hint` written for that case, so a rejection names what to do next instead of only what broke
239
+ - Size-delta self-correction — every mutating tool returns `previousSizeInBytes` / `currentSizeInBytes`, so a caller can spot an accidental clobber or unexpected upstream behavior without a follow-up read
240
+ - Ambiguity surfaced structurally — a heading leaf name shared by several headings returns `candidates` instead of silently picking one; tag operations report `applied` vs. `skipped` so a caller sees exactly what changed
241
+ - Discriminated output contracts — `format` on `obsidian_get_note`, `operation` on `obsidian_manage_frontmatter` and `obsidian_manage_tags`, `mode` on `obsidian_search_notes` — callers branch on typed fields instead of parsing text
221
242
 
222
243
  ## Getting started
223
244
 
@@ -259,6 +280,28 @@ Or with npx (no Bun required):
259
280
  }
260
281
  ```
261
282
 
283
+ Or with Docker:
284
+
285
+ ```json
286
+ {
287
+ "mcpServers": {
288
+ "obsidian-mcp-server": {
289
+ "type": "stdio",
290
+ "command": "docker",
291
+ "args": [
292
+ "run", "-i", "--rm",
293
+ "-e", "MCP_TRANSPORT_TYPE=stdio",
294
+ "-e", "MCP_LOG_LEVEL=info",
295
+ "-e", "OBSIDIAN_API_KEY=your-local-rest-api-key",
296
+ "ghcr.io/cyanheads/obsidian-mcp-server:latest"
297
+ ]
298
+ }
299
+ }
300
+ }
301
+ ```
302
+
303
+ The default `OBSIDIAN_BASE_URL` (`http://127.0.0.1:27123`) points at the container's own loopback, not your host — add `-e OBSIDIAN_BASE_URL=http://host.docker.internal:27123` (Docker Desktop) or run with `--network host` (Linux) so the container can reach the plugin.
304
+
262
305
  For Streamable HTTP, set the transport and start the server. Inline env vars work for one-off runs; for repeated use, copy values into `.env` (see [`.env.example`](./.env.example)) and run `bun run start:http`.
263
306
 
264
307
  ```sh
@@ -272,7 +315,7 @@ MCP_TRANSPORT_TYPE=http OBSIDIAN_API_KEY=... bun run start:http
272
315
  - The [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) plugin, **v4.0.0 through v5.x**, installed and enabled in your vault. Generate an API key in **Settings → Community Plugins → Local REST API** and copy it into `OBSIDIAN_API_KEY`. Plugin v6.0 removes the markdown-patch 1.x wire format this server pins for section-targeted writes and the document map.
273
316
  - Periodic-note targets (`target: { "type": "periodic" }`) work across that whole range: natively on plugin **v5.0.1 and earlier**, and on **v5.0.2 and later** — which moved the `/periodic/` routes out of the plugin — once the companion [periodic-notes API extension](https://github.com/coddingtonbear/obsidian-local-rest-api-periodic-notes) is installed. Without that extension on v5.0.2+, periodic targets fail with a `periodic_unsupported` error naming it; `obsidian://status` lists the registered extensions if you want to check first. Every other target type is unaffected.
274
317
  - An MCP client that can answer an input request (elicitation). `obsidian_delete_note` always asks for confirmation before deleting, so a client without that support can read and write notes but cannot delete one.
275
- - This server defaults to `http://127.0.0.1:27123` for simplicity. Enable **"Non-encrypted (HTTP) Server"** in the plugin settings to use it. To use the always-on HTTPS port instead, set `OBSIDIAN_BASE_URL=https://127.0.0.1:27124`; the plugin's self-signed cert is handled by `OBSIDIAN_VERIFY_SSL=false` (the default).
318
+ - This server defaults to `http://127.0.0.1:27123` for simplicity. Enable **"Non-encrypted (HTTP) Server"** in the plugin settings to use it. To use the always-on HTTPS port instead, set `OBSIDIAN_BASE_URL=https://127.0.0.1:27124`; the plugin's self-signed cert is handled by `OBSIDIAN_VERIFY_SSL=false` (the default), which relaxes verification for this server's requests to that endpoint only.
276
319
 
277
320
  ### Installation
278
321
 
@@ -306,8 +349,8 @@ MCP_TRANSPORT_TYPE=http OBSIDIAN_API_KEY=... bun run start:http
306
349
  | Variable | Description | Default |
307
350
  |:---------|:------------|:--------|
308
351
  | `OBSIDIAN_API_KEY` | **Required.** Bearer token for the Obsidian Local REST API plugin. | — |
309
- | `OBSIDIAN_BASE_URL` | Base URL of the Local REST API plugin. Use `https://127.0.0.1:27124` for the always-on HTTPS port (self-signed cert). | `http://127.0.0.1:27123` |
310
- | `OBSIDIAN_VERIFY_SSL` | Verify the TLS certificate. Default `false` because the plugin uses a self-signed cert. On Node, the dispatcher's `rejectUnauthorized` option handles this without any process-wide change. On Bun, the runtime ignores that option, so the service additionally sets `NODE_TLS_REJECT_UNAUTHORIZED=0` — that fallback is scoped to Bun only. | `false` |
352
+ | `OBSIDIAN_BASE_URL` | Base URL of the Local REST API plugin. Use `https://127.0.0.1:27124` for the always-on HTTPS port (self-signed cert). A trailing slash is stripped at startup. | `http://127.0.0.1:27123` |
353
+ | `OBSIDIAN_VERIFY_SSL` | Verify the TLS certificate. Default `false` because the plugin uses a self-signed cert. The relaxation is applied per request, to an `https:` `OBSIDIAN_BASE_URL` only — every other HTTPS connection the process makes still verifies normally, on both Bun and Node. | `false` |
311
354
  | `OBSIDIAN_REQUEST_TIMEOUT_MS` | Per-request timeout in milliseconds. | `30000` |
312
355
  | `OBSIDIAN_ENABLE_COMMANDS` | Opt-in flag for the command-palette pair (`obsidian_list_commands` + `obsidian_execute_command`). Off by default — Obsidian commands are opaque and can be destructive. | `false` |
313
356
  | `OBSIDIAN_READ_PATHS` | Comma-separated vault-relative folder allowlist for read operations. Prefix-based with implicit recursion; case-insensitive; trailing slashes normalized. Unset = full vault. Write paths are implicitly readable. | unset |
@@ -318,7 +361,7 @@ MCP_TRANSPORT_TYPE=http OBSIDIAN_API_KEY=... bun run start:http
318
361
  | `MCP_HTTP_HOST` | Host for the HTTP server. | `127.0.0.1` |
319
362
  | `MCP_HTTP_PORT` | Port for the HTTP server. | `3010` |
320
363
  | `MCP_HTTP_ENDPOINT_PATH` | Endpoint path for the JSON-RPC handler. | `/mcp` |
321
- | `MCP_SESSION_MODE` | Session handling for the HTTP transport: `stateless`, `stateful`, or `auto`. Pinned to `stateful` — `obsidian_delete_note` confirms via an elicitation round, which `stateless` disables. | `stateful` |
364
+ | `MCP_SESSION_MODE` | Session handling for the HTTP transport: `stateless`, `stateful`, or `auto`. Defaults to `stateful` here — `obsidian_delete_note` confirms via an elicitation round, and under `stateless` a 2025-era client's round is refused (`client_capability_missing`). | `stateful` |
322
365
  | `MCP_PUBLIC_URL` | Public origin override for TLS-terminating reverse-proxy deployments (landing page, Server Card, RFC 9728 metadata). | unset |
323
366
  | `MCP_AUTH_MODE` | Auth mode: `none`, `jwt`, or `oauth`. | `none` |
324
367
  | `MCP_AUTH_SECRET_KEY` | **Required when `MCP_AUTH_MODE=jwt`.** ≥32-char shared secret used to verify incoming JWTs. | — |
@@ -360,7 +403,7 @@ docker build -t obsidian-mcp-server .
360
403
  docker run --rm -e OBSIDIAN_API_KEY=your-key -p 3010:3010 obsidian-mcp-server
361
404
  ```
362
405
 
363
- The Dockerfile defaults to HTTP transport, stateless session mode, and logs to `/var/log/obsidian-mcp-server`. OpenTelemetry peer dependencies are installed by default — build with `--build-arg OTEL_ENABLED=false` to omit them.
406
+ The Dockerfile defaults to HTTP transport, stateful session mode (required for the `obsidian_delete_note` confirmation round), and logs to `/var/log/obsidian-mcp-server`. Point `OBSIDIAN_BASE_URL` at `http://host.docker.internal:27123` (Docker Desktop) or run with `--network host` (Linux) so the container reaches the plugin on your host. OpenTelemetry peer dependencies are installed by default — build with `--build-arg OTEL_ENABLED=false` to omit them.
364
407
 
365
408
  The image binds to `0.0.0.0` inside the container (required for Docker port mapping). For any deployment reachable beyond your own machine, set `MCP_AUTH_MODE=jwt` (with `MCP_AUTH_SECRET_KEY`) or `oauth` — otherwise the listener forwards your `OBSIDIAN_API_KEY` to the vault on behalf of every caller.
366
409