obsidian-mcp-server 3.6.0 → 3.7.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 (79) hide show
  1. package/AGENTS.md +40 -27
  2. package/CLAUDE.md +40 -27
  3. package/Dockerfile +90 -47
  4. package/README.md +130 -151
  5. package/changelog/3.6.x/3.6.1.md +29 -0
  6. package/changelog/3.7.x/3.7.0.md +34 -0
  7. package/dist/config/server-config.d.ts +1 -0
  8. package/dist/config/server-config.d.ts.map +1 -1
  9. package/dist/config/server-config.js +4 -0
  10. package/dist/config/server-config.js.map +1 -1
  11. package/dist/index.js +30 -10
  12. package/dist/index.js.map +1 -1
  13. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts +7 -5
  14. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts.map +1 -1
  15. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js +12 -8
  16. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +32 -10
  18. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +30 -8
  20. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/index.d.ts +230 -294
  22. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/index.js +9 -3
  24. package/dist/mcp-server/tools/definitions/index.js.map +1 -1
  25. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +1 -1
  26. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +108 -10
  28. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
  29. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +254 -133
  30. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
  31. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +1 -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 +62 -21
  34. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
  35. package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.js +1 -2
  37. package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.js.map +1 -1
  38. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +6 -1
  39. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
  40. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +9 -12
  41. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
  42. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts +7 -5
  43. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts.map +1 -1
  44. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +14 -9
  45. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
  46. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -1
  47. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +1 -2
  48. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -1
  49. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +2 -2
  50. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
  51. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +4 -1
  52. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts.map +1 -1
  53. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +15 -4
  54. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
  55. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts.map +1 -1
  56. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +2 -3
  57. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
  58. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
  59. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +2 -5
  60. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
  61. package/dist/services/obsidian/obsidian-service.d.ts +7 -0
  62. package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
  63. package/dist/services/obsidian/obsidian-service.js +72 -35
  64. package/dist/services/obsidian/obsidian-service.js.map +1 -1
  65. package/dist/services/obsidian/patch-instruction.d.ts +20 -1
  66. package/dist/services/obsidian/patch-instruction.d.ts.map +1 -1
  67. package/dist/services/obsidian/patch-instruction.js +30 -0
  68. package/dist/services/obsidian/patch-instruction.js.map +1 -1
  69. package/dist/services/obsidian/path-policy.d.ts +23 -1
  70. package/dist/services/obsidian/path-policy.d.ts.map +1 -1
  71. package/dist/services/obsidian/path-policy.js +45 -3
  72. package/dist/services/obsidian/path-policy.js.map +1 -1
  73. package/dist/services/obsidian/section-extractor.d.ts +24 -0
  74. package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
  75. package/dist/services/obsidian/section-extractor.js +37 -0
  76. package/dist/services/obsidian/section-extractor.js.map +1 -1
  77. package/manifest.json +9 -1
  78. package/package.json +13 -10
  79. package/server.json +24 -24
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.6.0-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.7.0-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.2.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.2%2B-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -23,204 +23,168 @@
23
23
 
24
24
  ## Overview
25
25
 
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.
26
+ Obsidian vault notes over the Local REST API plugin. Read, search, and write notes, edit single headings, blocks, and frontmatter fields in place, and manage tags, with folder-scoped read/write permissions built in. Runs as a stdio process or a local Streamable HTTP server.
27
27
 
28
28
  ### Tools
29
29
 
30
30
  | Tool | Description |
31
31
  |:---|:---|
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. |
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. |
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. |
35
- | `obsidian_list_commands` | List Obsidian command-palette commands, optionally filtered by `nameRegex` on display name. **Opt-in via `OBSIDIAN_ENABLE_COMMANDS=true`** (paired with `obsidian_execute_command`). |
36
- | `obsidian_search_notes` | Search the vault by text, JSONLogic, or BM25-ranked Omnisearch (when the plugin is reachable). Results paginate via opaque cursors. |
37
- | `obsidian_write_note` | Create a note, replace a single section in place, or — with `overwrite: true` — clobber an existing file. Refuses whole-file writes against an existing path by default. |
38
- | `obsidian_append_to_note` | Append content to a note. Without `section`, creates the file if missing. With `section`, appends to a specific heading, block, or frontmatter field (file must exist). |
39
- | `obsidian_patch_note` | Surgical `append` / `prepend` / `replace` against a heading, block reference, or frontmatter field. |
40
- | `obsidian_replace_in_note` | Search-replace inside a single note, scoped to the body by default. Literal or regex matching with whole-word, whitespace-flexible, and case-sensitivity options; supports capture-group replacement. |
41
- | `obsidian_manage_frontmatter` | Atomic `get` / `set` / `delete` on a single frontmatter key. |
42
- | `obsidian_manage_tags` | Add, remove, or list tags. Defaults to the frontmatter `tags:` array; `location: 'inline'` or `'both'` opts into mutating the note body. |
43
- | `obsidian_delete_note` | Permanently delete a note. Always asks the user to confirm first — the call is answered with a confirmation request and retried with the answer. |
44
- | `obsidian_open_in_ui` | Open a file in the Obsidian app UI, with `failIfMissing` and `newLeaf` toggles. |
45
- | `obsidian_execute_command` | Execute an Obsidian command-palette command by ID. **Opt-in via `OBSIDIAN_ENABLE_COMMANDS=true`.** |
32
+ | `obsidian_get_note` | Read a note as raw content, full structured form, document map, or a single section |
33
+ | `obsidian_list_notes` | List notes and folders under a vault path, recursively, with extension and name filters |
34
+ | `obsidian_list_tags` | List vault tags with usage counts, most-used first |
35
+ | `obsidian_search_notes` | Search by text, JSONLogic, or BM25-ranked Omnisearch when that plugin is reachable |
36
+ | `obsidian_write_note` | Create a note, replace one section, or overwrite a whole file with `overwrite: true` |
37
+ | `obsidian_append_to_note` | Append to a note (creating it if missing) or to one heading, block, or frontmatter field |
38
+ | `obsidian_patch_note` | Append, prepend, or replace against one heading, block reference, or frontmatter field |
39
+ | `obsidian_replace_in_note` | Literal or regex search-replace inside one note, body-only by default |
40
+ | `obsidian_manage_frontmatter` | Get, set, or delete one frontmatter key |
41
+ | `obsidian_manage_tags` | Add, remove, or list a note's tags in frontmatter, inline, or both |
42
+ | `obsidian_delete_note` | Permanently delete a note, optionally after the user confirms (`OBSIDIAN_DELETE_ELICITATION`) |
43
+ | `obsidian_open_in_ui` | Open a file in the Obsidian app, optionally in a new pane |
44
+ | `obsidian_list_commands` | List command-palette commands (opt-in via `OBSIDIAN_ENABLE_COMMANDS`) |
45
+ | `obsidian_execute_command` | Run a command-palette command by ID (opt-in via `OBSIDIAN_ENABLE_COMMANDS`) |
46
46
 
47
47
  ### Resources
48
48
 
49
49
  | Resource | Description |
50
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. |
51
+ | `obsidian://vault/{+path}` | A note's content, frontmatter, tags, and file metadata |
52
+ | `obsidian://tags` | Every vault tag with its usage count, as an uncapped snapshot |
53
+ | `obsidian://status` | Plugin reachability, auth status, versions, and registered API extensions |
54
54
 
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.
55
+ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidian_list_tags`); `obsidian://status` has no tool equivalent.
56
56
 
57
57
  ## Capability reference
58
58
 
59
59
  ### `obsidian_get_note` <sub>tool</sub>
60
60
 
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 and find headings the way the document map does — setext headings (underlined with `===` or `---`) count, `#` lines inside a list item, HTML block, or fence do not — so every heading path the map lists reads back as itself, over the same span a section write edits; a bare leaf name matching several headings, or a full path that repeats in the note, 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`
61
+ - `target` is a vault `path`, the `active` file, or a `periodic` note (`daily` through `yearly`, optional `date`); `format` is `content`, `full`, `document-map`, or `section`, and `full` takes `includeLinks: true` for vault-internal outgoing links
62
+ - `result.format` discriminates the payload; a `section` read that matches several headings returns the first and lists every full path in `candidates`
63
+ - A `path` with no exact match but one case-insensitive match in its folder reads that file: `result.path` is the real name, `requestedPath` the one sent, and a `notice` names both
66
64
 
67
65
  ---
68
66
 
69
67
  ### `obsidian_list_notes` <sub>tool</sub>
70
68
 
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
69
+ - Walks from `path` (default vault root) to `depth` 1–20 (default 2), filtered by `extension` and `nameRegex` (≤256 chars); a folder that fails `nameRegex` is not walked
70
+ - Returns `entries[]` (`file` / `directory`), `totals`, and `appliedFilters`; the walk stops at 1,000 entries with `excluded.reason: "entry_cap"`, and a folder the depth limit stopped carries `truncated: true`
71
+ - With `OBSIDIAN_READ_PATHS` set, a listing holds only readable entries and the folders leading to the scope; those folders are walked and accepted as `path`
75
72
 
76
73
  ---
77
74
 
78
75
  ### `obsidian_list_tags` <sub>tool</sub>
79
76
 
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
84
-
85
- ---
86
-
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`
77
+ - `nameRegex` (≤256 chars) and `minCount` narrow the set, then tags are ranked by count and capped at `limit` (default 200, max 10000); hierarchical parents count (`work/tasks` adds to `work`)
78
+ - When the cap withholds tags, the response carries `truncated`, `shown`, and `cap`
79
+ - With `OBSIDIAN_READ_PATHS` set, only tags from readable notes are listed, and `count` is the number of readable notes carrying the tag or a tag nested under it
92
80
 
93
81
  ---
94
82
 
95
83
  ### `obsidian_search_notes` <sub>tool</sub>
96
84
 
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` — whitespace-split tokens, all required, each matched case-insensitively as a substring (quotes are literal, so there is no phrase operator), with `contextLength`-sized context windows (default 100) and an optional `pathPrefix`; tokens within 2 × `contextLength` of each other, such as a phrase's words, share one match location; `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` match locations (default 10), flagged with `truncated` / `totalMatches`
100
- - No dedicated backlinks tool — express "what links here" via `jsonlogic`: `{"regexp": ["\\[\\[Target Note(\\||#|\\]\\])", {"var": "content"}]}`
85
+ - `mode: "text"` requires every whitespace-split token of `query` as a case-insensitive substring of the filename or body (quotes are literal), shaped by `contextLength` (default 100), `pathPrefix`, and `maxMatchesPerHit` (default 10); `mode: "jsonlogic"` evaluates a `logic` tree over `path`, `content`, `frontmatter.<key>`, `tags`, and `stat`, with `glob` / `regexp` taking `[PATTERN, VALUE]`
86
+ - `result.mode` discriminates the payload; every mode reports `totalCount` and pages via `nextCursor`, and a text hit clipped to `maxMatchesPerHit` carries `truncated` and `totalMatches`
87
+ - `mode: "omnisearch"` (BM25 ranking, quoted phrases, `-exclusion`, `path:` / `ext:` filters) is offered only when the Omnisearch plugin answered at startup; its 50-hit upstream cap sets `truncated: true`
101
88
 
102
89
  ---
103
90
 
104
91
  ### `obsidian_write_note` <sub>tool</sub>
105
92
 
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` unless one of them has no parent heading, which the write then targets, and a full heading path that repeats in the note fails the same way
108
- - Output reports `created`, plus `previousSizeInBytes` / `currentSizeInBytes` on every call to spot an accidental clobber or a mistyped path
93
+ - `target` and `content`, with optional `section` and `contentType` (`markdown` / `json`); a whole-file write to an existing note fails with `file_exists` unless `overwrite: true`
94
+ - With `section`, replaces only that heading, block, or frontmatter field and keeps the heading line; output reports `created`, `sectionTargeted`, and the resolved `sectionTarget`
109
95
 
110
96
  ---
111
97
 
112
98
  ### `obsidian_append_to_note` <sub>tool</sub>
113
99
 
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. On plugin v5.0 and later, content appended to a heading is separated from the section's existing content by a blank line (except a list item appended to a section that ends in a list and has no sub-headings, which continues that list), and a heading in it must sit below the section's own level (`heading_outside_section` otherwise)
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
100
+ - Without `section`, appends to the file or creates it (`created: true`); with `section`, appends to that heading, block, or frontmatter field of an existing note, and `createTargetIfMissing: true` creates the section
101
+ - A section append whose content is already at the target fails with `content_preexists`; block targets add no separator, so start `content` with a newline if you want one
118
102
 
119
103
  ---
120
104
 
121
105
  ### `obsidian_patch_note` <sub>tool</sub>
122
106
 
123
- - `operation: "append" | "prepend" | "replace"` against one heading, block reference, or frontmatter field per call; on plugin v5.0 and later, content appended or prepended to a heading is separated from the section's existing content by a blank line (except a list item appended to a section that ends in a list and has no sub-headings, or prepended to one that opens with a list, which continues that list), and a heading in it must sit below the section's own level (`heading_outside_section` otherwise)
124
- - Heading targets accept the full `Parent::Child` path or a bare leaf name; a leaf matching several headings fails with `ambiguous_section` and lists the candidates, unless one of them has no parent heading, which the patch then targets; a full path that repeats in the note fails with `ambiguous_section` too
125
- - `patchOptions`: `createTargetIfMissing`, `applyIfContentPreexists` (idempotency guard — otherwise `content_preexists`), `trimTargetWhitespace` (plugin v4.x only; v5.0 and later place the blank lines around inserted content themselves)
107
+ - `operation: "append" | "prepend" | "replace"` against one `section` of an existing note; `patchOptions` takes `createTargetIfMissing`, `applyIfContentPreexists`, and `trimTargetWhitespace` (plugin v4.x only)
108
+ - Echoes the resolved `section` and `operation`; a repeat of content already at the target fails with `content_preexists` unless `applyIfContentPreexists: true`
126
109
 
127
110
  ---
128
111
 
129
112
  ### `obsidian_replace_in_note` <sub>tool</sub>
130
113
 
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
114
+ - `replacements[]` run in order, each over the previous one's output; each takes `useRegex` (≤1024 chars), `caseSensitive` (default `true`), `wholeWord`, `flexibleWhitespace` (literal mode only), and `replaceAll` (default `true`)
115
+ - Returns `totalReplacements` and `perReplacement[]` with `bodyCount` / `frontmatterCount`
116
+ - `scope: "body"` (default) leaves frontmatter byte-identical; `"frontmatter"` and `"both"` re-parse the YAML afterward and write nothing if it breaks (`frontmatter_invalid`)
135
117
 
136
118
  ---
137
119
 
138
120
  ### `obsidian_manage_frontmatter` <sub>tool</sub>
139
121
 
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`
122
+ - `operation: "get" | "set" | "delete"` on one `key`; `set` requires a JSON-typed `value`
123
+ - `get` returns `exists` and `value` (`null` when absent); `set` and `delete` return the full `frontmatter` after the change, and a `delete` against unparseable YAML fails with `frontmatter_invalid` without writing
143
124
 
144
125
  ---
145
126
 
146
127
  ### `obsidian_manage_tags` <sub>tool</sub>
147
128
 
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 code (fenced, indented, and inline), wikilinks (`[[...]]`), images, a markdown link's destination or label (its text is read), HTML blocks and comments, and math (`$…$`, `$$…$$`), so a heading anchor or wikilink alias is never mistaken for a tag; `%% … %%` comments are still read, as Obsidian reads them
150
- - Inline tags follow Obsidian's grammar: a tag starts at line start, after whitespace, after another tag (`#a#b` is two tags), or right after markup such as `**`, `_…_`, `==`, `[`, a table cell's `|`, `<br>`, or a `\`-escape (`**#x**` is a tag; `(#x`, `.#x`, `a *#x`, and `\#x` are not) and runs through letters and digits in any script, emoji, `_`, `-`, and `/`, with at least one character that is not an ASCII digit (`#1990s`, `#café`, `#日本語`, and `#✅done` are tags; `#1984` is not)
151
- - `add` / `remove` report `applied` vs. `skipped` tags plus the full `tags` set after the change; `list` ignores the input `tags` array
129
+ - `operation: "add" | "remove" | "list"` with `tags`; `location: "frontmatter"` (default, the `tags:` array), `"inline"` (body `#tag`; `add` appends at end of file), or `"both"`
130
+ - `add` / `remove` report `applied`, `skipped`, and the resulting `tags`; `list` returns `frontmatter`, `inline`, and `all`
131
+ - Inline detection follows Obsidian's tag grammar: code, wikilinks, images, link destinations, HTML, and math are skipped, and `%% … %%` comments are read
152
132
 
153
133
  ---
154
134
 
155
135
  ### `obsidian_delete_note` <sub>tool</sub>
156
136
 
157
- - 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`
158
- - No API-level undo — recovery requires Obsidian's local trash
159
- - Requires an MCP client that can serve an elicitation round-trip; every other tool works without one
137
+ - Takes a `target` (path, active file, or periodic note) and checks it against the write scope first; a folder path fails with `path_is_directory`. There is no API-level undo, only Obsidian's local trash
138
+ - The path must match exactly, letter case included, even on a case-insensitive filesystem. A different-case or extension-less spelling fails `note_missing` before anything is deleted, naming the near matches in `suggestions`
139
+ - By default the note is deleted on the first call, with no confirmation; `OBSIDIAN_WRITE_PATHS` and `OBSIDIAN_READ_ONLY` bound what it can reach
140
+ - With `OBSIDIAN_DELETE_ELICITATION=true`, the first call answers with a confirmation request naming the path and byte size, and the note is deleted only after the user accepts. Declining fails with `cancelled`, and a client without elicitation support cannot delete
141
+ - In that mode an answer counts only against the single-use consent record stored when the prompt was shown, bound to the caller, the path, and the note's content then. A pre-supplied or replayed answer, or one given after the note changed, gets a fresh prompt instead
142
+ - Consent records live in the server's storage provider (`STORAGE_PROVIDER_TYPE`, default `in-memory`, process-local). That works for stdio or a single HTTP instance; several instances behind one endpoint need a shared provider (`filesystem`, `supabase`, or `cloudflare-d1`, never `cloudflare-kv`)
160
143
 
161
144
  ---
162
145
 
163
146
  ### `obsidian_open_in_ui` <sub>tool</sub>
164
147
 
165
- - `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
166
- - `newLeaf` opens in a split pane instead of the active one
167
- - 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
168
- - Output reports `createdIfMissing` so the caller can tell which branch ran
148
+ - `path`, `failIfMissing` (default `true`), and `newLeaf` (open in a split pane); with `failIfMissing: false` a missing file is created, which needs write access
149
+ - `createdIfMissing` reports which branch ran
150
+ - A case-mismatched `path` with one case-insensitive match opens that file instead of creating another, reporting the path sent as `requestedPath` with a `notice`
151
+
152
+ ---
153
+
154
+ ### `obsidian_list_commands` <sub>tool</sub>
155
+
156
+ - Optional `nameRegex` (≤256 chars) matched against each command's display name
157
+ - Returns `commands[]` of `id` and `name`, where `id` feeds `obsidian_execute_command`
158
+ - Listed only when `OBSIDIAN_ENABLE_COMMANDS=true` and `OBSIDIAN_READ_ONLY` is off
169
159
 
170
160
  ---
171
161
 
172
162
  ### `obsidian_execute_command` <sub>tool</sub>
173
163
 
174
- - Dispatches an Obsidian command-palette command by `commandId` (discover via `obsidian_list_commands`); runs with the same authority as a keyboard invocation
175
- - **Opt-in via `OBSIDIAN_ENABLE_COMMANDS=true`** — absent from `tools/list` when unset
176
- - Behavior is command-dependent — some are destructive (delete file, close vault), some open UI
164
+ - `commandId` from `obsidian_list_commands`; returns `executed: true`, or fails with `command_unknown` for an unregistered ID
165
+ - Runs with the authority of a keyboard shortcut, so some commands are destructive (delete file, close vault); gated like `obsidian_list_commands`
177
166
 
178
167
  ---
179
168
 
180
169
  ### `obsidian://vault/{+path}` <sub>resource</sub>
181
170
 
182
- - The `{+path}` segment captures everything after `/vault/`, including slashes
183
- - 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 `%`
184
- - Returns the same shape as `obsidian_get_note` with `format: "full"` — content, frontmatter, tags, stat
185
- - Gated by `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` like the tool equivalent
171
+ - `{+path}` captures everything after `/vault/`, slashes included; literal and percent-encoded paths resolve to the same note
172
+ - Returns `path`, `content`, `frontmatter`, `tags`, and `stat`, the same shape as `obsidian_get_note` with `format: "full"`; failures are `path_forbidden`, `note_missing`, or `path_is_directory`
186
173
 
187
174
  ---
188
175
 
189
176
  ### `obsidian://tags` <sub>resource</sub>
190
177
 
191
- - Full snapshot of the upstream `/tags/` payload — unsorted, uncapped, includes hierarchical parents
192
- - Not a mirror of `obsidian_list_tags`: no count-descending order, no `limit` / `nameRegex` / `minCount`
178
+ - Every tag with its `count`, uncapped and unsorted, hierarchical parents included
179
+ - With `OBSIDIAN_READ_PATHS` set, only tags from readable notes, counted the same way as `obsidian_list_tags`
180
+ - No ranking or filters; `obsidian_list_tags` gives the count-ranked, capped view
193
181
 
194
182
  ---
195
183
 
196
184
  ### `obsidian://status` <sub>resource</sub>
197
185
 
198
- - Reachability, plugin version, `authenticated` (whether the configured `OBSIDIAN_API_KEY` was accepted), and plugin manifest info
199
- - `apiExtensions[]` lists registered plugin extensions — check for `local-rest-api-periodic-notes` before relying on `periodic` targets on plugin v5.0.2 and later
200
- - Still reports reachability when the API key is misconfigured; only `authenticated` reflects the key's validity
201
-
202
- ## Path policy (folder-scoped permissions)
203
-
204
- Three optional env vars gate which vault paths each tool can target. **Default unset = full vault** for both reads and writes — backwards compatible.
205
-
206
- | Goal | Config |
207
- |:---|:---|
208
- | Default (current behavior) | all unset |
209
- | Read everywhere, write only in `projects/` and `scratch/` | `OBSIDIAN_WRITE_PATHS=projects/,scratch/` |
210
- | Read only `public/`, write only `public/inbox/` | `OBSIDIAN_READ_PATHS=public/`, `OBSIDIAN_WRITE_PATHS=public/inbox/` |
211
- | Read-only deployment — no writes anywhere | `OBSIDIAN_READ_ONLY=true` |
212
-
213
- **Matching is prefix-based with implicit recursion**, case-insensitive, with trailing slashes normalized. `projects/` matches `projects/a.md`, `projects/sub/b.md`, etc.
214
-
215
- **Write paths are implicitly readable** — you can't sanely edit what you can't see. So a read passes when the target matches `READ_PATHS` *or* `WRITE_PATHS`.
216
-
217
- **`OBSIDIAN_READ_ONLY=true` short-circuits before the path checks** — every write tool and the command-palette pair are wrapped with `disabledTool()` at startup (absent from `tools/list`), and any write that still reaches the service is denied at runtime regardless of `WRITE_PATHS`.
218
-
219
- Denies are typed `path_forbidden` (JSON-RPC code `Forbidden`) with the active scope echoed back in `data.recovery.hint` and `data.activeScope`, so the LLM can self-correct without inspecting server logs. Search results from `obsidian_search_notes` are filtered against `READ_PATHS` silently — surfacing a "we hid N hits" indicator would defeat the gate.
220
-
221
- **Tag listing is vault-wide.** `obsidian_list_tags` and the `obsidian://tags` resource aggregate tag names across the whole vault and are *not* narrowed by `OBSIDIAN_READ_PATHS` — they take no path to gate, so tag names (never note contents) from outside the read scope can surface.
222
-
223
- The startup banner logs the active scope so operators can verify their config at boot.
186
+ - `status`, `service`, `authenticated`, `versions`, `manifest`, and `apiExtensions[]`; still answers with a wrong API key, reporting `authenticated: false`
187
+ - On plugin v5.0.2 and later, check `apiExtensions` for `local-rest-api-periodic-notes` before using `periodic` targets
224
188
 
225
189
  ## Features
226
190
 
@@ -228,22 +192,23 @@ Built on [`@cyanheads/mcp-ts-core`](https://github.com/cyanheads/mcp-ts-core): s
228
192
 
229
193
  Obsidian-specific:
230
194
 
231
- - Wraps the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) plugin — typed client, deterministic error mapping
232
- - Section-aware editing across headings, block references, and frontmatter fields via `PATCH`-with-target operations
233
- - Search across three modes — text, JSONLogic, and (when reachable) BM25-ranked Omnisearch — cursor-paginated per the MCP 2025-11-25 spec
234
- - Tag reconciliation across both representations: frontmatter `tags:` array and inline `#tag` syntax
235
- - 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
195
+ - Typed client for the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) plugin; section writes speak markdown-patch 2.0 to plugin v5.0 and later and 1.x to v4.x, chosen from the reported plugin version
196
+ - Heading targets take a full `Parent::Child` path or a bare leaf name; writes reject an ambiguous one with `ambiguous_section` and its `candidates`, unless exactly one match is a top-level heading. On plugin v5.0 and later, content added to a heading is set off by a blank line (a list item continues an adjacent list), and a heading inside it must sit below the section's level (`heading_outside_section`)
197
+ - Folder-scoped read/write permissions, a read-only switch, and an opt-in command-palette pair (see [Path policy](#path-policy)); server-level `instructions` on `initialize` report the active policy
198
+ - `obsidian_get_note` and `obsidian_open_in_ui` retry a case-mismatched path against the real filename, disclosed as `requestedPath` plus a `notice`, and add `Did you mean` suggestions to a miss; writes and deletes match the exact path
199
+ - Regex inputs are capped (`nameRegex` at 256 chars, `useRegex` at 1024) and rejected with `regex_unsafe` when they nest quantifiers
200
+ - Backlinks have no dedicated tool; `obsidian_search_notes` in `jsonlogic` mode finds them with `{"regexp": ["\\[\\[Target Note(\\\\?\\||#|\\]\\])", {"var": "content"}]}`, which also matches the table-safe `[[Target Note\|Alias]]`
236
201
 
237
202
  Agent-friendly output:
238
203
 
239
- - 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
240
- - 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
241
- - 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
242
- - 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
204
+ - Recovery-guided errors: every declared failure carries a `reason`, a JSON-RPC code, and a `recovery.hint` written for that case
205
+ - Size deltas: every mutating tool returns `previousSizeInBytes` / `currentSizeInBytes`, so a caller can spot an accidental clobber without a follow-up read
206
+ - Ambiguity surfaced as data: shared heading names return `candidates`, and tag operations report `applied` vs. `skipped`
207
+ - Discriminated output contracts: `format` on `obsidian_get_note`, `mode` on `obsidian_search_notes`, `operation` on `obsidian_manage_frontmatter` and `obsidian_manage_tags`
243
208
 
244
209
  ## Getting started
245
210
 
246
- Add the following to your MCP client configuration file. The Obsidian Local REST API plugin must be installed and enabled in your vault — see [Prerequisites](#prerequisites).
211
+ Add the following to your MCP client configuration file. The Obsidian Local REST API plugin must be installed and enabled in your vault; see [Prerequisites](#prerequisites).
247
212
 
248
213
  ```json
249
214
  {
@@ -301,9 +266,9 @@ Or with Docker:
301
266
  }
302
267
  ```
303
268
 
304
- 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.
269
+ Inside a container, the default `OBSIDIAN_BASE_URL` (`http://127.0.0.1:27123`) is the container's own loopback. Add `-e OBSIDIAN_BASE_URL=http://host.docker.internal:27123` (Docker Desktop) or run with `--network host` (Linux) to reach the plugin on your host.
305
270
 
306
- 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`.
271
+ For Streamable HTTP, set the transport and start the server:
307
272
 
308
273
  ```sh
309
274
  MCP_TRANSPORT_TYPE=http OBSIDIAN_API_KEY=... bun run start:http
@@ -313,10 +278,12 @@ MCP_TRANSPORT_TYPE=http OBSIDIAN_API_KEY=... bun run start:http
313
278
  ### Prerequisites
314
279
 
315
280
  - [Bun v1.4.0](https://bun.sh/) or higher (or Node.js v24+).
316
- - The [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) plugin, **v4.0.0 or later**, installed and enabled in your vault. Generate an API key in **Settings → Community Plugins → Local REST API** and copy it into `OBSIDIAN_API_KEY`. Section-targeted writes and the document map speak markdown-patch 2.0 to plugin v5.0 and later and the 1.x format to v4.x; the server reads the plugin version once and picks the format itself. Two table-row writes (`contentType: "json"`) that markdown-patch 2.0 cannot express go out as 1.x on v5.x too: rows written under a heading, and rows written through a block ID on its own line below the table. Plugin v6.0 removes 1.x, so on v6.0 those two shapes fail; target the table by an ID on its last row instead.
317
- - 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.
318
- - 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.
319
- - 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.
281
+ - The [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) plugin, v4.0.0 or later, enabled in your vault. Generate an API key under **Settings → Community Plugins → Local REST API** and set it as `OBSIDIAN_API_KEY`.
282
+ - The server defaults to `http://127.0.0.1:27123`, so enable **"Non-encrypted (HTTP) Server"** in the plugin settings, or set `OBSIDIAN_BASE_URL=https://127.0.0.1:27124` for the always-on HTTPS port (its self-signed cert is accepted while `OBSIDIAN_VERIFY_SSL=false`, the default).
283
+ - Periodic-note targets work natively on plugin v5.0.1 and earlier. From v5.0.2 they need the [periodic-notes API extension](https://github.com/coddingtonbear/obsidian-local-rest-api-periodic-notes); without it they fail with `periodic_unsupported`.
284
+ - On plugin v4.x, heading section writes to a note with CRLF (Windows) line endings land mid-line: the plugin's markdown-patch 1.x engine misplaces them, and the server can't correct it. Plugin v5.0 and later handle CRLF notes.
285
+ - Plugin v6.0 drops markdown-patch 1.x, which two table-row writes (`contentType: "json"`) still use: rows under a heading, and rows through a block ID on its own line below the table. On v6.0, target the table by an ID on its last row.
286
+ - With `OBSIDIAN_DELETE_ELICITATION=true`, an MCP client that supports elicitation and renders its form, to use `obsidian_delete_note`. Every other tool, and the default delete, works without it.
320
287
 
321
288
  ### Installation
322
289
 
@@ -349,30 +316,42 @@ MCP_TRANSPORT_TYPE=http OBSIDIAN_API_KEY=... bun run start:http
349
316
 
350
317
  | Variable | Description | Default |
351
318
  |:---------|:------------|:--------|
352
- | `OBSIDIAN_API_KEY` | **Required.** Bearer token for the Obsidian Local REST API plugin. | — |
353
- | `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. When nothing answers there (Obsidian closed, plugin disabled, wrong host or port), calls fail with `obsidian_unreachable` — a `GET`, `PUT`, or `DELETE` after its retries, any other request on the first attempt. | `http://127.0.0.1:27123` |
354
- | `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. With `true`, a certificate the runtime does not trust fails every call on its first attempt with `certificate_rejected`. | `false` |
319
+ | `OBSIDIAN_API_KEY` | **Required.** Bearer token for the Local REST API plugin. | — |
320
+ | `OBSIDIAN_BASE_URL` | Local REST API base URL; `https://127.0.0.1:27124` is the always-on HTTPS port. A trailing slash is stripped. When nothing answers, calls fail with `obsidian_unreachable`. | `http://127.0.0.1:27123` |
321
+ | `OBSIDIAN_VERIFY_SSL` | Verify the plugin's TLS certificate. Off by default for its self-signed cert; the relaxation applies only to an `https:` `OBSIDIAN_BASE_URL`. With `true`, an untrusted cert fails calls with `certificate_rejected`. | `false` |
355
322
  | `OBSIDIAN_REQUEST_TIMEOUT_MS` | Per-request timeout in milliseconds. | `30000` |
356
- | `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` |
357
- | `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 |
358
- | `OBSIDIAN_WRITE_PATHS` | Comma-separated vault-relative folder allowlist for write operations. Same syntax as `OBSIDIAN_READ_PATHS`. Unset = full vault. | unset |
359
- | `OBSIDIAN_READ_ONLY` | Global kill switch. When `true`, denies every write regardless of `OBSIDIAN_WRITE_PATHS`, and suppresses the `OBSIDIAN_ENABLE_COMMANDS` pair (commands can mutate). | `false` |
360
- | `OBSIDIAN_OMNISEARCH_URL` | Override URL for the [Omnisearch](https://github.com/scambier/obsidian-omnisearch) plugin's HTTP server. When unset, derives from `OBSIDIAN_BASE_URL` host with port `51361` (falling back to `http://localhost:51361`). Probed once at startup — if reachable, the `omnisearch` mode is added to `obsidian_search_notes`; otherwise it's omitted from the tool schema. Restart the server to re-probe. | derived |
323
+ | `OBSIDIAN_ENABLE_COMMANDS` | Enable `obsidian_list_commands` and `obsidian_execute_command`. Commands are opaque and can be destructive. | `false` |
324
+ | `OBSIDIAN_READ_PATHS` | Comma-separated folder allowlist for reads. See [Path policy](#path-policy). | unset (full vault) |
325
+ | `OBSIDIAN_WRITE_PATHS` | Comma-separated folder allowlist for writes. See [Path policy](#path-policy). | unset (full vault) |
326
+ | `OBSIDIAN_READ_ONLY` | Deny every write and disable the command-palette pair. | `false` |
327
+ | `OBSIDIAN_DELETE_ELICITATION` | Ask the user to confirm each `obsidian_delete_note` call through an elicitation form before deleting. Needs a client that renders elicitation, and a stateful session over HTTP (not required when `OBSIDIAN_READ_ONLY=true` disables the tool). Off, the delete runs on the first call. | `false` |
328
+ | `OBSIDIAN_OMNISEARCH_URL` | [Omnisearch](https://github.com/scambier/obsidian-omnisearch) HTTP server URL. Unset derives from the `OBSIDIAN_BASE_URL` host on port `51361`. Probed once at startup; the `omnisearch` search mode appears only if it answers. | derived |
361
329
  | `MCP_TRANSPORT_TYPE` | Transport: `stdio` or `http`. | `stdio` |
362
- | `MCP_HTTP_HOST` | Host for the HTTP server. | `127.0.0.1` |
363
- | `MCP_HTTP_PORT` | Port for the HTTP server. | `3010` |
364
- | `MCP_HTTP_ENDPOINT_PATH` | Endpoint path for the JSON-RPC handler. | `/mcp` |
365
- | `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` |
366
- | `MCP_PUBLIC_URL` | Public origin override for TLS-terminating reverse-proxy deployments (landing page, Server Card, RFC 9728 metadata). | unset |
367
- | `MCP_AUTH_MODE` | Auth mode: `none`, `jwt`, or `oauth`. | `none` |
368
- | `MCP_AUTH_SECRET_KEY` | **Required when `MCP_AUTH_MODE=jwt`.** ≥32-char shared secret used to verify incoming JWTs. | — |
369
- | `MCP_AUTH_DISABLE_SCOPE_CHECKS` | When `true`, bypasses per-tool scope enforcement after the auth-context presence check. Token signature, audience, issuer, and expiry validation remain intact. Use only when a custom claim can't be injected and combine with `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` for access control. A `WARNING` is logged at startup whenever the bypass is active. | `false` |
330
+ | `MCP_HTTP_PORT` | HTTP server port. | `3010` |
331
+ | `MCP_SESSION_MODE` | HTTP session mode: `stateful`, `stateless`, or `auto`. With `OBSIDIAN_DELETE_ELICITATION=true` and `OBSIDIAN_READ_ONLY` off, the server requires a stateful session, because the confirmation needs one on 2025-era clients, so `stateless` fails startup over HTTP. No effect on stdio. | `stateful` |
332
+ | `MCP_AUTH_MODE` | Authentication: `none`, `jwt`, or `oauth`. | `none` |
370
333
  | `MCP_LOG_LEVEL` | Log level (RFC 5424). | `info` |
371
334
  | `LOGS_DIR` | Directory for log files (Node.js only). | `<project-root>/logs` |
372
- | `OTEL_ENABLED` | Enable [OpenTelemetry instrumentation](https://github.com/cyanheads/mcp-ts-core/tree/main/docs/telemetry) (spans, metrics, completion logs). | `false` |
335
+ | `OTEL_ENABLED` | Enable [OpenTelemetry](https://github.com/cyanheads/mcp-ts-core/tree/main/docs/telemetry). | `false` |
373
336
 
374
337
  See [`.env.example`](./.env.example) for the full list of optional overrides.
375
338
 
339
+ ### Path policy
340
+
341
+ Three optional env vars limit which vault paths the tools can touch. Unset, reads and writes cover the full vault.
342
+
343
+ | Goal | Config |
344
+ |:---|:---|
345
+ | Read everywhere, write only in `projects/` and `scratch/` | `OBSIDIAN_WRITE_PATHS=projects/,scratch/` |
346
+ | Read only `public/`, write only `public/inbox/` | `OBSIDIAN_READ_PATHS=public/`, `OBSIDIAN_WRITE_PATHS=public/inbox/` |
347
+ | No writes anywhere | `OBSIDIAN_READ_ONLY=true` |
348
+
349
+ - Matching is by prefix, recursive, and case-insensitive; trailing slashes are normalized. Write paths are also readable.
350
+ - `OBSIDIAN_READ_ONLY=true` removes every write tool and the command-palette pair from `tools/list`, including `obsidian_manage_frontmatter` and `obsidian_manage_tags` (so their `get` / `list` go too). `obsidian_open_in_ui` still opens existing files but won't create one.
351
+ - A denial fails with `path_forbidden`, echoing the active scope in `data.activeScope` and the recovery hint. Search hits outside the read scope are dropped silently, and `obsidian_list_notes` lists only readable entries plus the folders leading to the scope, so a nested scope can be browsed to from the root.
352
+ - Tag listings (`obsidian_list_tags`, `obsidian://tags`) under `OBSIDIAN_READ_PATHS` cover readable notes only, counting notes rather than occurrences. Write paths and read-only alone leave them vault-wide.
353
+ - The startup log prints the active scope.
354
+
376
355
  ## Running the server
377
356
 
378
357
  ### Local development
@@ -404,22 +383,22 @@ docker build -t obsidian-mcp-server .
404
383
  docker run --rm -e OBSIDIAN_API_KEY=your-key -p 3010:3010 obsidian-mcp-server
405
384
  ```
406
385
 
407
- 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.
386
+ The image defaults to HTTP transport on `0.0.0.0`, stateful sessions, and logs in `/var/log/obsidian-mcp-server`. Point `OBSIDIAN_BASE_URL` at `http://host.docker.internal:27123` (Docker Desktop) or use `--network host` (Linux) to reach the plugin. OpenTelemetry peer dependencies are installed by default; build with `--build-arg OTEL_ENABLED=false` to omit them.
408
387
 
409
- 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.
388
+ If the port is reachable from other machines, set `MCP_AUTH_MODE=jwt` (with `MCP_AUTH_SECRET_KEY`) or `oauth`. With the default `none`, every caller acts on your vault with your `OBSIDIAN_API_KEY`.
410
389
 
411
390
  ## Project structure
412
391
 
413
392
  | Directory | Purpose |
414
393
  |:----------|:--------|
415
- | `src/index.ts` | `createApp()` entry point — registers tools/resources and inits the Obsidian service. |
394
+ | `src/index.ts` | `createApp()` entry point: registers tools and resources, probes Omnisearch, applies the read-only and command gates. |
416
395
  | `src/config` | Server-specific environment variable parsing (`OBSIDIAN_*`) with Zod. |
417
- | `src/services/obsidian` | Local REST API client, frontmatter operations, section extractor, domain types. |
418
- | `src/mcp-server/tools` | Tool definitions (`*.tool.ts`) and shared input schemas. |
396
+ | `src/services/obsidian` | Local REST API client, path policy, markdown-patch format handling, frontmatter/section/tag parsing, domain types. |
397
+ | `src/mcp-server/tools` | Tool definitions (`*.tool.ts`) and shared schemas and helpers. |
419
398
  | `src/mcp-server/resources` | Resource definitions (`*.resource.ts`). |
420
- | `src/mcp-server/prompts` | Prompt definitions (currently empty — CRUD/search shape doesn't benefit from a structured template). |
421
- | `tests/` | Vitest tests mirroring `src/`. |
422
- | `docs/` | Upstream OpenAPI spec for the Local REST API plugin and the generated `tree.md`. |
399
+ | `src/mcp-server/prompts` | Prompt definitions (none registered). |
400
+ | `tests/` | Vitest tests for tools, resources, services, and config. |
401
+ | `docs/` | Local REST API OpenAPI spec and the generated `tree.md`. |
423
402
  | `changelog/` | Per-version release notes; `CHANGELOG.md` is the regenerated rollup. |
424
403
 
425
404
  ## Development guide
@@ -427,13 +406,13 @@ The image binds to `0.0.0.0` inside the container (required for Docker port mapp
427
406
  See [`CLAUDE.md`](./CLAUDE.md) for development guidelines and architectural rules. The short version:
428
407
 
429
408
  - Handlers throw, framework catches — no `try/catch` in tool logic
430
- - Use `ctx.log` for request-scoped logging, `ctx.state` for tenant-scoped storage
409
+ - Use `ctx.log` for request-scoped logging; route every Local REST API call through `getObsidianService()`
431
410
  - Register new tools and resources via the barrels in `src/mcp-server/*/definitions/index.ts`
432
411
  - Wrap external API calls: validate raw → normalize to domain type → return output schema; never fabricate missing fields
433
412
 
434
413
  ## Contributing
435
414
 
436
- Bugs, feature requests, and documentation gaps belong in an issue — see [CONTRIBUTING.md](.github/CONTRIBUTING.md) for what makes one actionable, and [CODE_OF_CONDUCT.md](.github/CODE_OF_CONDUCT.md) for how we work together. Security reports go through [SECURITY.md](.github/SECURITY.md), never a public issue.
415
+ Bugs, feature requests, and documentation gaps belong in an issue; see [CONTRIBUTING.md](.github/CONTRIBUTING.md) for what makes one actionable and [CODE_OF_CONDUCT.md](.github/CODE_OF_CONDUCT.md) for how we work together. Security reports go through [SECURITY.md](.github/SECURITY.md), never a public issue.
437
416
 
438
417
  Run checks and tests before submitting:
439
418
 
@@ -0,0 +1,29 @@
1
+ ---
2
+ summary: "obsidian_delete_note now deletes only on a round that redeems a server-stored consent record, HTTP startup requires a stateful session, and the server moves to mcp-ts-core 0.13.12, whose tool errors carry a request ID and whose argument hints lead with the field path."
3
+ breaking: true
4
+ security: true
5
+ ---
6
+
7
+ # 3.6.1 — 2026-10-05
8
+
9
+ ## Changed
10
+
11
+ - **HTTP startup requires a stateful session.** `createApp` declares `sessionMode: { default: 'stateful', require: 'stateful' }`: over HTTP, a resolved `stateless` mode (`MCP_SESSION_MODE=stateless`) now fails startup with a `ConfigurationError` instead of serving a delete confirmation no 2025-era client can answer. stdio starts regardless of the variable. Before, stateful was only the default and an explicit `stateless` still started.
12
+ - **`obsidian_delete_note` reads the note once (GET) to build its confirmation prompt**, replacing the size probe (HEAD).
13
+ - **`obsidian_append_to_note` description** now says a line break is inserted before content appended to a file that does not end in one.
14
+ - **A table-patch rejection now reads "the target is not a table"** instead of "the target block is not a table".
15
+ - **Docker:** dependencies install in a `deps` stage pinned to `$BUILDPLATFORM` (OTel peers via `scripts/install-otel.ts`, musl bindings pruned by `scripts/prune-musl-packages.ts`), base images move to Bun 1.4.2, and the image no longer sets `MCP_SESSION_MODE` (the session mode comes from `src/`) or `MCP_FORCE_CONSOLE_LOGGING` (nothing read it).
16
+ - **`server.json`:** npm entries drop the `run start:*` package arguments and set `runtimeHint: "npx"`; the `streamable-http` entry fixes `MCP_TRANSPORT_TYPE` to `http`.
17
+ - **mcp-ts-core 0.13.12:** tool error results carry `data.requestId` and their text closes with `request <id>` (0.13.10); a declared `recovery` hint reaches the client without the handler forwarding it, so the server's 29 `ctx.recoveryFor` forwards are removed with no change to the hints clients receive (0.13.10); argument-rejection hints lead with the field path, e.g. `format: Invalid option: …` (0.13.9); server stack traces (0.13.7) and request context (0.13.8) no longer appear in error `data`.
18
+ - README rewritten; package keywords add `obsidian-md` and `pkm`; `dist/*.mcpb` is excluded from the npm package; `.gitignore`, `.mcpbignore`, `.dockerignore`, and `.env.example` updated; framework skills, scripts, and the CodeQL workflow re-synced.
19
+
20
+ ## Security
21
+
22
+ - **`obsidian_delete_note` deletes only on a round that redeems the consent record the server stored when it asked** (operation, caller, resolved path, SHA-256 of the note content). A replayed or pre-supplied confirmation, a made-up `requestState`, or a record for another note, caller, or since-changed content gets a fresh prompt instead of a deletion. Before, an accepted `confirm` answer alone was enough, and a client can send an answer on a call nothing prompted for: mcp-ts-core 0.13.10 limits that to clients that declared elicitation, and the record closes it for them. Records live in `ctx.state` (default in-memory, process-local): a multi-instance HTTP deployment needs a shared storage provider.
23
+
24
+ ## Dependencies
25
+
26
+ - `@cyanheads/mcp-ts-core` ^0.13.6 → ^0.13.12
27
+ - `undici` ^8.10.2 → ^8.11.2
28
+ - dev: `@biomejs/biome` ^2.5.14 → ^2.5.15, `@socketsecurity/bun-security-scanner` ^1.1.2 → ^1.1.3, `@types/node` ^26.6.2 → ^26.6.4, `ignore` ^7.0.9 → ^7.0.11, `tsc-alias` ^1.9.5 → ^1.9.7, `vitest` ^5.0.1 → ^5.0.3
29
+ - `marked` stays pinned at `17.0.6` (the Local REST API's markdown-patch engine is on marked 17; marked 18 changes block-token spans)