obsidian-mcp-server 3.6.1 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/AGENTS.md +40 -22
  2. package/CLAUDE.md +40 -22
  3. package/Dockerfile +3 -3
  4. package/README.md +29 -17
  5. package/changelog/3.7.x/3.7.0.md +34 -0
  6. package/changelog/3.7.x/3.7.1.md +40 -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 +5 -1
  10. package/dist/config/server-config.js.map +1 -1
  11. package/dist/index.js +35 -35
  12. package/dist/index.js.map +1 -1
  13. package/dist/instructions.d.ts +20 -0
  14. package/dist/instructions.d.ts.map +1 -0
  15. package/dist/instructions.js +40 -0
  16. package/dist/instructions.js.map +1 -0
  17. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts +7 -5
  18. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts.map +1 -1
  19. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js +12 -8
  20. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +69 -9
  22. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +88 -7
  24. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
  25. package/dist/mcp-server/tools/definitions/index.d.ts +256 -309
  26. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/index.js +9 -3
  28. package/dist/mcp-server/tools/definitions/index.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +6 -0
  30. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -1
  31. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +12 -4
  32. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
  33. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +107 -10
  34. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
  35. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +237 -162
  36. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
  37. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +1 -0
  38. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
  39. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +70 -24
  40. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
  41. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +6 -1
  42. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
  43. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +8 -10
  44. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
  45. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts +7 -5
  46. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts.map +1 -1
  47. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +13 -7
  48. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
  49. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -1
  50. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +6 -4
  51. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -1
  52. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
  53. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +4 -2
  54. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
  55. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +4 -1
  56. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts.map +1 -1
  57. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +15 -4
  58. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
  59. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +2 -2
  60. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
  61. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts.map +1 -1
  62. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +4 -2
  63. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
  64. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +26 -2
  65. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
  66. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +47 -15
  67. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
  68. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +5 -0
  69. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
  70. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +21 -4
  71. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
  72. package/dist/services/obsidian/frontmatter-ops.d.ts +15 -1
  73. package/dist/services/obsidian/frontmatter-ops.d.ts.map +1 -1
  74. package/dist/services/obsidian/frontmatter-ops.js +55 -10
  75. package/dist/services/obsidian/frontmatter-ops.js.map +1 -1
  76. package/dist/services/obsidian/jsonlogic-evaluator.d.ts +83 -0
  77. package/dist/services/obsidian/jsonlogic-evaluator.d.ts.map +1 -0
  78. package/dist/services/obsidian/jsonlogic-evaluator.js +192 -0
  79. package/dist/services/obsidian/jsonlogic-evaluator.js.map +1 -0
  80. package/dist/services/obsidian/jsonlogic-worker.d.ts +53 -0
  81. package/dist/services/obsidian/jsonlogic-worker.d.ts.map +1 -0
  82. package/dist/services/obsidian/jsonlogic-worker.js +237 -0
  83. package/dist/services/obsidian/jsonlogic-worker.js.map +1 -0
  84. package/dist/services/obsidian/obsidian-service.d.ts +61 -6
  85. package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
  86. package/dist/services/obsidian/obsidian-service.js +301 -53
  87. package/dist/services/obsidian/obsidian-service.js.map +1 -1
  88. package/dist/services/obsidian/patch-instruction.d.ts +20 -1
  89. package/dist/services/obsidian/patch-instruction.d.ts.map +1 -1
  90. package/dist/services/obsidian/patch-instruction.js +30 -0
  91. package/dist/services/obsidian/patch-instruction.js.map +1 -1
  92. package/dist/services/obsidian/path-policy.d.ts +55 -5
  93. package/dist/services/obsidian/path-policy.d.ts.map +1 -1
  94. package/dist/services/obsidian/path-policy.js +143 -19
  95. package/dist/services/obsidian/path-policy.js.map +1 -1
  96. package/dist/services/obsidian/section-extractor.d.ts +24 -0
  97. package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
  98. package/dist/services/obsidian/section-extractor.js +37 -0
  99. package/dist/services/obsidian/section-extractor.js.map +1 -1
  100. package/dist/services/obsidian/types.d.ts +12 -3
  101. package/dist/services/obsidian/types.d.ts.map +1 -1
  102. package/manifest.json +10 -2
  103. package/package.json +18 -14
  104. package/server.json +19 -5
package/AGENTS.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Agent Protocol
2
2
 
3
3
  **Server:** obsidian-mcp-server
4
- **Version:** 3.6.1
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.12`
4
+ **Version:** 3.7.1
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.15`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
- **MCP SDK:** `@modelcontextprotocol/server` ^2.2.0
8
- **Zod:** ^4.6.5
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.3.1
8
+ **Zod:** ^4.6.7
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
 
@@ -34,12 +34,12 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
34
34
 
35
35
  - **Logic throws, framework catches.** Tool/resource handlers are pure — throw on failure, no `try/catch`. Plain `Error` is fine; the framework catches, classifies, and formats. Use error factories (`notFound()`, `validationError()`, etc.) when the error code matters.
36
36
  - **Use `ctx.log`** for request-scoped logging. No `console` calls.
37
- - **Confirm destructive ops with `ctx.requestInput`, gated on a consent record.** `obsidian_delete_note` redeems (reads and deletes) the `ctx.state` record named by `ctx.inputs.state()` before anything else, and acts on the `ctx.inputs` answer only when that record deep-equals `{ operation, clientId, subject, target, contentHash }` for this call; otherwise it stores a fresh record (`ttl: 600`) and `return ctx.requestInput(...)` with the record id as `requestState`. An answer alone is never proof the user was asked. Never `await` user input mid-handler.
37
+ - **The delete confirmation is opt-in; when on, it is gated on a consent record.** `OBSIDIAN_DELETE_ELICITATION` (default `false`) decides whether `obsidian_delete_note` confirms through `ctx.requestInput`. Off is the default for two reasons: a client that declares elicitation but cannot render the form declines every round automatically, which the server cannot tell from a real decline (#151), and the round forces a stateful HTTP session, which locks out `MCP_SESSION_MODE=stateless`. Off, `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` bound what a delete can reach — the same bound `obsidian_write_note` with `overwrite: true` has — and `destructiveHint` stays for client-side approval. The mode is the operator's choice alone: never derive it from `ctx.clientCapabilities` or from a decline. On, the handler redeems (reads and deletes) the `ctx.state` record named by `ctx.inputs.state()` before anything else, checks write scope on the resolved path before it reads the note or asks, and acts on the `ctx.inputs` answer only when that record deep-equals `{ operation, clientId, subject, target, contentHash }` for this call; otherwise it stores a fresh record (`ttl: 600`) and `return ctx.requestInput(...)` with the record id as `requestState`. An answer alone is never proof the user was asked. Never `await` user input mid-handler.
38
38
  - **All Obsidian access goes through `getObsidianService()`.** No direct `fetch()` calls to the Local REST API in tools/resources — the service centralizes auth, TLS, timeouts, and `ctx.signal` propagation.
39
39
  - **Secrets in env vars only.** `OBSIDIAN_API_KEY` is required; never hardcoded.
40
40
  - **Cut noise.** Add only what earns its place: no speculative generality, no guards for states the framework already prevents (Zod-validated params, classified errors), no abstraction until a third caller proves it, no option nothing sets.
41
41
  - **Command-palette tools are opt-in.** `obsidian_list_commands` and `obsidian_execute_command` are callable only when `OBSIDIAN_ENABLE_COMMANDS=true` — Obsidian commands are opaque and can be destructive. When the flag is unset, the entry point wraps both with `disabledTool()` so they're absent from `tools/list` (LLM can't invoke) but visible in the operator-facing manifest with a hint to enable them.
42
- - **Path-policy gating goes through `PathPolicy`.** Every path-taking method on `ObsidianService` calls `policy.assertReadable` / `assertWritable` before the upstream HTTP call; `obsidian_search_notes` post-filters hits via `svc.policy.filterReadable`. Don't bypass this — `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` are the single chokepoint, and `path_forbidden` is declared on every path-taking tool's `errors[]` contract.
42
+ - **Path-policy gating goes through `PathPolicy`.** Every path-taking method on `ObsidianService` calls `policy.assertReadable` / `assertWritable` before the upstream HTTP call; `listFiles` gates with `assertListable` and filters entries with `filterListing` (readable entries plus folders on the way to the scope), so every listing consumer inherits the scope; `obsidian_search_notes` filters hits via `svc.policy.filterReadable`. Under `policy.restrictsReads`, `searchJsonLogic` never sends the caller's tree upstream: one `/search/` guarded by `readScopePattern` fetches the readable notes' contexts (plus one body fetch per batch of readable notes when the tree names `content`), `filterReadable` gates them, `keepReadable` reduces `links` / `backlinks` / `unresolvedLinks`, and `createJsonLogicEvaluation` runs the tree on a worker thread with the plugin's operators, bounded by depth, a per-note build budget, a per-search kept-result budget, and a deadline (`logic_too_costly`; a note too deep to hand to the worker is `note_too_deep`), so no hit, result, or error reads a note outside the scope. Link targets are still Obsidian's vault-wide resolution, so the reduced link fields can shift when an out-of-scope note appears or disappears (#165); docs state that bound, never a stronger one. `listTags` reads per-note tags from the same readable contexts, with no evaluation. An `active` / `periodic` target is gated on the path it resolves to, under the target's label: `resolveTarget` / `pinTarget` return a `PathTarget` whose `resolvedFrom` the asserts take, so a denial names `(active file)` or `daily note for 2026-10-01`, never a path the caller did not send, and a tool that reads a target then writes it writes to that pinned path rather than resolving the target again. Don't bypass this — `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` are the single chokepoint, and `path_forbidden` is declared on every path-taking tool's `errors[]` contract.
43
43
  - **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them.
44
44
 
45
45
  ---
@@ -48,7 +48,7 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
48
48
 
49
49
  ### Tool — `obsidian_list_tags`
50
50
 
51
- A small read-only tool that wraps a single upstream endpoint, normalizes the response into the output schema, and renders a markdown twin in `format()`. Reduced for illustration — the live definition also carries `nameRegex` / `minCount` / `limit` inputs, the count-descending sort and cap they feed, an `errors[]` contract, and an `enrichment` block.
51
+ A small read-only tool that wraps a single service call, normalizes the response into the output schema, and renders a markdown twin in `format()`. Reduced for illustration — the live definition also carries `nameRegex` / `minCount` / `limit` inputs, the count-descending sort and cap they feed, an `errors[]` contract, and an `enrichment` block.
52
52
 
53
53
  ```ts
54
54
  import { tool, z } from '@cyanheads/mcp-ts-core';
@@ -64,7 +64,11 @@ export const obsidianListTags = tool('obsidian_list_tags', {
64
64
  .array(
65
65
  z.object({
66
66
  name: z.string().describe('Tag name without the leading `#`.'),
67
- count: z.number().describe('Usage count across the vault.'),
67
+ count: z
68
+ .number()
69
+ .describe(
70
+ 'Times the tag, or a tag nested under it, occurs across the vault. When OBSIDIAN_READ_PATHS is set: the number of readable notes carrying the tag or a tag nested under it.',
71
+ ),
68
72
  }).describe('A tag with its usage count.'),
69
73
  )
70
74
  .describe('Matching tags ordered by `count` descending.'),
@@ -92,7 +96,7 @@ export const obsidianListTags = tool('obsidian_list_tags', {
92
96
  });
93
97
  ```
94
98
 
95
- For a destructive tool with human-in-the-loop confirmation, see `obsidian-delete-note.tool.ts` — it redeems a single-use consent record from `ctx.state` (bound to the operation, the caller, the resolved path, and a SHA-256 of the note's content), suspends with `ctx.requestInput` for an embedded `elicitation/create` round when no matching record backs the answer, branches on `ctx.inputs.view()` so a declined prompt on a matching round fails instead of re-asking, and carries the `destructiveHint` annotation. Pattern: framework `api-context` § Consent gates.
99
+ For a destructive tool with optional human-in-the-loop confirmation, see `obsidian-delete-note.tool.ts`. `src/index.ts` builds it with `buildDeleteNoteTool({ elicitation })` from `OBSIDIAN_DELETE_ELICITATION`, so the description states the active mode; the exported `obsidianDeleteNote` is the off build, kept as the specimen the definition linter and tests import. Both modes check write scope on the resolved path, then read the note before the DELETE. That read is the folder guard (`path_is_directory`), since the Local REST API's `DELETE` on a folder path removes the folder and everything in it. It is also the exact-case guard, so it goes through `note+json` (`getNoteJson`), never the raw-markdown read. On a case-insensitive filesystem the raw read and the plugin v4.x `DELETE` (`adapter.exists` + `adapter.remove`) case-fold, so a wrong-case path would read and then permanently remove the differently-cased note. `note+json` resolves through Obsidian's case-sensitive vault index on v4.x and v5.x, so a wrong-case path fails `note_missing` before any DELETE, with near matches listed in `suggestions` and never substituted. With the confirmation on, it redeems a single-use consent record from `ctx.state` (bound to the operation, the caller, the resolved path, and a SHA-256 of the note's content), suspends with `ctx.requestInput` for an embedded `elicitation/create` round when no matching record backs the answer, and branches on `ctx.inputs.view()` so a declined prompt on a matching round fails instead of re-asking. Only the confirming build declares `cancelled` in `errors[]`, since the off build never throws it. Both builds carry the `destructiveHint` annotation. Pattern: framework `api-context` § Consent gates.
96
100
 
97
101
  ### Resource — `obsidian://status`
98
102
 
@@ -132,11 +136,8 @@ This server exposes a CRUD/search surface; no recurring multi-turn pattern benef
132
136
  import { z } from '@cyanheads/mcp-ts-core';
133
137
  import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
134
138
 
135
- const envBoolean = z.preprocess((val) => {
136
- if (val === undefined || val === null || val === '') return;
137
- if (typeof val === 'boolean') return val;
138
- return String(val).toLowerCase().trim() === 'true' || val === '1';
139
- }, z.boolean());
139
+ /** true/false/1/0/yes/no/on/off, case-insensitive; anything else fails startup. */
140
+ const envBoolean = z.union([z.boolean(), z.stringbool()]);
140
141
 
141
142
  const ServerConfigSchema = z.object({
142
143
  apiKey: z.string().min(1).describe('Bearer token for the Obsidian Local REST API plugin.'),
@@ -148,6 +149,9 @@ const ServerConfigSchema = z.object({
148
149
  readPaths: envPathList,
149
150
  writePaths: envPathList,
150
151
  readOnly: envBoolean.default(false),
152
+ /** Opt-in delete confirmation round; when true and writes are allowed, HTTP requires a stateful session. */
153
+ deleteElicitation: envBoolean.default(false),
154
+ omnisearchUrl: z.string().url().optional(),
151
155
  });
152
156
 
153
157
  let _config: z.infer<typeof ServerConfigSchema> | undefined;
@@ -161,6 +165,8 @@ export function getServerConfig() {
161
165
  readPaths: 'OBSIDIAN_READ_PATHS',
162
166
  writePaths: 'OBSIDIAN_WRITE_PATHS',
163
167
  readOnly: 'OBSIDIAN_READ_ONLY',
168
+ deleteElicitation: 'OBSIDIAN_DELETE_ELICITATION',
169
+ omnisearchUrl: 'OBSIDIAN_OMNISEARCH_URL',
164
170
  });
165
171
  return _config;
166
172
  }
@@ -173,16 +179,20 @@ export function getServerConfig() {
173
179
  `src/index.ts` passes two `createApp()` options that shape how the server runs:
174
180
 
175
181
  ```ts
182
+ const deleteGateActive = config.deleteElicitation && !config.readOnly;
183
+
176
184
  await createApp({
177
185
  // ...
178
- sessionMode: { default: 'stateful', require: 'stateful' },
186
+ sessionMode: deleteGateActive
187
+ ? { default: 'stateful', require: 'stateful' }
188
+ : { default: 'stateful' },
179
189
  teardown: () => obsidian.close(),
180
190
  });
181
191
  ```
182
192
 
183
- `sessionMode` declares a requirement, not just a default: `obsidian_delete_note` confirms through `ctx.requestInput`, which a 2025-era HTTP client can only answer on a stateful session, so under stateless HTTP the tool would be unusable for those clients. Unset, `MCP_SESSION_MODE` runs `stateful`; when the resolved HTTP mode is `stateless` (an explicit `MCP_SESSION_MODE=stateless`), startup fails with a `ConfigurationError` naming the conflict. stdio is never refused — `MCP_SESSION_MODE` has no effect there. `tests/integration/delete-note-confirmation.test.ts` pins both.
193
+ Unset, `MCP_SESSION_MODE` runs `stateful`. With `OBSIDIAN_DELETE_ELICITATION=true`, `sessionMode` declares a requirement, not just a default: `obsidian_delete_note` then confirms through `ctx.requestInput`, which a 2025-era HTTP client can only answer on a stateful session, so under stateless HTTP the tool would be unusable for those clients. In that mode, when the resolved HTTP mode is `stateless` (an explicit `MCP_SESSION_MODE=stateless`), startup fails with a `ConfigurationError` naming the conflict. With the confirmation off (the default), or with `OBSIDIAN_READ_ONLY=true` (which disables `obsidian_delete_note`, so no round can run), nothing needs a session and stateless HTTP starts. stdio is never refused — `MCP_SESSION_MODE` has no effect there. `tests/integration/delete-note-confirmation.test.ts` pins each case. The startup "Path policy" log line reports `deleteElicitation` as `deleteGateActive` — false under read-only, as `enableCommands` is.
184
194
 
185
- The delete confirmation's consent records live in `ctx.state`, backed by the framework storage provider (`STORAGE_PROVIDER_TYPE`, default `in-memory`, process-local). That holds for stdio and for a single stateful HTTP instance, where every round of a confirmation reaches the process that asked. A multi-instance deployment, where a retry can land on another instance, needs a shared provider — `filesystem`, `supabase`, or `cloudflare-d1`, never `cloudflare-kv` (eventually consistent, so a record may be unseen or outlive its redemption). Redemption is single-use against a sequential replay but not against concurrent retries until the framework has an atomic `ctx.state.take` (cyanheads/mcp-ts-core#593).
195
+ The delete confirmation's consent records (on mode only) live in `ctx.state`, backed by the framework storage provider (`STORAGE_PROVIDER_TYPE`, default `in-memory`, process-local). That holds for stdio and for a single stateful HTTP instance, where every round of a confirmation reaches the process that asked. A multi-instance deployment, where a retry can land on another instance, needs a shared provider — `filesystem`, `supabase`, or `cloudflare-d1`, never `cloudflare-kv` (eventually consistent, so a record may be unseen or outlive its redemption). Redemption is single-use against a sequential replay but not against concurrent retries until the framework has an atomic `ctx.state.take` (cyanheads/mcp-ts-core#593).
186
196
 
187
197
  `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.
188
198
 
@@ -195,8 +205,8 @@ Handlers receive a unified `ctx` object. Properties this server actually uses:
195
205
  | Property | Description |
196
206
  |:---------|:------------|
197
207
  | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. |
198
- | `ctx.requestInput` / `ctx.inputs` | Multi-round-trip human-in-the-loop confirmation. Always present, both protocol eras — `obsidian_delete_note` requests a confirmation round before the DELETE. `requestInput` returns `never`, so write it in return position. |
199
- | `ctx.state` | Tenant-scoped KV. Used only for `obsidian_delete_note`'s consent records (`consent/<uuid>`, 600 s TTL) — see Session posture above for provider requirements. |
208
+ | `ctx.requestInput` / `ctx.inputs` | Multi-round-trip human-in-the-loop confirmation. Always present, both protocol eras — with `OBSIDIAN_DELETE_ELICITATION=true`, `obsidian_delete_note` requests a confirmation round before the DELETE; off, it reads neither. `requestInput` returns `never`, so write it in return position. |
209
+ | `ctx.state` | Tenant-scoped KV. Used only for `obsidian_delete_note`'s consent records (`consent/<uuid>`, 600 s TTL), written only with `OBSIDIAN_DELETE_ELICITATION=true` — see Session posture above for provider requirements. |
200
210
  | `ctx.auth` | Caller identity (`clientId`, `sub`) bound into each consent record; absent on stdio and `MCP_AUTH_MODE=none`, where the record carries empty strings. |
201
211
  | `ctx.signal` | `AbortSignal` propagated to the Local REST API client so per-request timeouts and client cancellations cut off in-flight HTTP. |
202
212
  | `ctx.requestId` | Unique request ID — surfaces in log lines for correlation. |
@@ -264,20 +274,26 @@ Available factories: `notFound`, `validationError`, `forbidden`, `unauthorized`,
264
274
  ```text
265
275
  src/
266
276
  index.ts # createApp() entry point — registers tools/resources, inits Obsidian service
277
+ instructions.ts # buildInstructions() — server `instructions`, effective path scope included
267
278
  config/
268
279
  server-config.ts # OBSIDIAN_* env vars (Zod schema)
269
280
  services/
270
281
  obsidian/
271
282
  obsidian-service.ts # Local REST API client (init/accessor pattern)
272
- frontmatter-ops.ts # YAML frontmatter parse/serialize/edit helpers + inline tag reader
283
+ path-policy.ts # PathPolicy — OBSIDIAN_READ_PATHS / WRITE_PATHS / READ_ONLY gate
284
+ jsonlogic-deps.d.ts # Type declarations for json-logic-js and glob-to-regexp
285
+ jsonlogic-evaluator.ts # Scoped JSONLogic evaluation: bounds, worker hand-off, logic_* errors
286
+ jsonlogic-worker.ts # Worker entry: the plugin's JSONLogic operators, hardened and budgeted
287
+ frontmatter-ops.ts # YAML frontmatter parse/serialize/edit helpers + inline tag reader + the wikilink span rule (`wikilinkCloser`) `obsidian_get_note` imports
273
288
  markdown-blocks.ts # Block structure (code, HTML, math, tables) for inline tag detection
274
289
  patch-instruction.ts # markdown-patch 1.x headers / 2.0 instructions, format negotiation, 2.0 map flattening
275
290
  section-extractor.ts # Heading/block/frontmatter section extraction
276
- types.ts # Domain types (NoteJson, NoteTarget, etc.)
291
+ types.ts # Domain types (NoteJson, NoteTarget, PathTarget, etc.)
277
292
  mcp-server/
278
293
  tools/definitions/
279
294
  _shared/schemas.ts # Shared TargetSchema + SectionSchema reused across tools
280
- index.ts # read/write/command tool sets + buildSearchNotesTool factory (Omnisearch-aware)
295
+ _shared/suggest-paths.ts # Case-mismatch fallback and suggestions for reads; names the existing note on a `case_variant_exists` write
296
+ index.ts # read/write/command tool sets + buildSearchNotesTool (Omnisearch-aware) and buildDeleteNoteTool (elicitation mode) factories
281
297
  obsidian-*.tool.ts # 14 tool definitions (12 base + 2 opt-in command-palette pair)
282
298
  resources/definitions/
283
299
  index.ts # allResourceDefinitions[]
@@ -371,6 +387,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
371
387
  | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity, `user_config` wiring, plugin manifest identity and env contract (run by devcheck) |
372
388
  | `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install |
373
389
  | `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) |
390
+ | `bun run test:coverage` | Run tests with Istanbul coverage |
374
391
  | `bun run start:stdio` | Production mode (stdio) — requires `bun run build` first |
375
392
  | `bun run start:http` | Production mode (HTTP) — requires `bun run build` first |
376
393
  | `bun run changelog:build` | Regenerate `CHANGELOG.md` rollup from `changelog/<minor>.x/*.md` |
@@ -425,6 +442,7 @@ security: false # optional — true ONLY for a source
425
442
  bun publish --access public
426
443
 
427
444
  docker buildx build --platform linux/amd64,linux/arm64 \
445
+ --build-arg APP_VERSION=<version> \
428
446
  -t ghcr.io/cyanheads/obsidian-mcp-server:<version> \
429
447
  -t ghcr.io/cyanheads/obsidian-mcp-server:latest \
430
448
  --push .
package/CLAUDE.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Agent Protocol
2
2
 
3
3
  **Server:** obsidian-mcp-server
4
- **Version:** 3.6.1
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.12`
4
+ **Version:** 3.7.1
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.15`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
- **MCP SDK:** `@modelcontextprotocol/server` ^2.2.0
8
- **Zod:** ^4.6.5
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.3.1
8
+ **Zod:** ^4.6.7
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
 
@@ -34,12 +34,12 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
34
34
 
35
35
  - **Logic throws, framework catches.** Tool/resource handlers are pure — throw on failure, no `try/catch`. Plain `Error` is fine; the framework catches, classifies, and formats. Use error factories (`notFound()`, `validationError()`, etc.) when the error code matters.
36
36
  - **Use `ctx.log`** for request-scoped logging. No `console` calls.
37
- - **Confirm destructive ops with `ctx.requestInput`, gated on a consent record.** `obsidian_delete_note` redeems (reads and deletes) the `ctx.state` record named by `ctx.inputs.state()` before anything else, and acts on the `ctx.inputs` answer only when that record deep-equals `{ operation, clientId, subject, target, contentHash }` for this call; otherwise it stores a fresh record (`ttl: 600`) and `return ctx.requestInput(...)` with the record id as `requestState`. An answer alone is never proof the user was asked. Never `await` user input mid-handler.
37
+ - **The delete confirmation is opt-in; when on, it is gated on a consent record.** `OBSIDIAN_DELETE_ELICITATION` (default `false`) decides whether `obsidian_delete_note` confirms through `ctx.requestInput`. Off is the default for two reasons: a client that declares elicitation but cannot render the form declines every round automatically, which the server cannot tell from a real decline (#151), and the round forces a stateful HTTP session, which locks out `MCP_SESSION_MODE=stateless`. Off, `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` bound what a delete can reach — the same bound `obsidian_write_note` with `overwrite: true` has — and `destructiveHint` stays for client-side approval. The mode is the operator's choice alone: never derive it from `ctx.clientCapabilities` or from a decline. On, the handler redeems (reads and deletes) the `ctx.state` record named by `ctx.inputs.state()` before anything else, checks write scope on the resolved path before it reads the note or asks, and acts on the `ctx.inputs` answer only when that record deep-equals `{ operation, clientId, subject, target, contentHash }` for this call; otherwise it stores a fresh record (`ttl: 600`) and `return ctx.requestInput(...)` with the record id as `requestState`. An answer alone is never proof the user was asked. Never `await` user input mid-handler.
38
38
  - **All Obsidian access goes through `getObsidianService()`.** No direct `fetch()` calls to the Local REST API in tools/resources — the service centralizes auth, TLS, timeouts, and `ctx.signal` propagation.
39
39
  - **Secrets in env vars only.** `OBSIDIAN_API_KEY` is required; never hardcoded.
40
40
  - **Cut noise.** Add only what earns its place: no speculative generality, no guards for states the framework already prevents (Zod-validated params, classified errors), no abstraction until a third caller proves it, no option nothing sets.
41
41
  - **Command-palette tools are opt-in.** `obsidian_list_commands` and `obsidian_execute_command` are callable only when `OBSIDIAN_ENABLE_COMMANDS=true` — Obsidian commands are opaque and can be destructive. When the flag is unset, the entry point wraps both with `disabledTool()` so they're absent from `tools/list` (LLM can't invoke) but visible in the operator-facing manifest with a hint to enable them.
42
- - **Path-policy gating goes through `PathPolicy`.** Every path-taking method on `ObsidianService` calls `policy.assertReadable` / `assertWritable` before the upstream HTTP call; `obsidian_search_notes` post-filters hits via `svc.policy.filterReadable`. Don't bypass this — `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` are the single chokepoint, and `path_forbidden` is declared on every path-taking tool's `errors[]` contract.
42
+ - **Path-policy gating goes through `PathPolicy`.** Every path-taking method on `ObsidianService` calls `policy.assertReadable` / `assertWritable` before the upstream HTTP call; `listFiles` gates with `assertListable` and filters entries with `filterListing` (readable entries plus folders on the way to the scope), so every listing consumer inherits the scope; `obsidian_search_notes` filters hits via `svc.policy.filterReadable`. Under `policy.restrictsReads`, `searchJsonLogic` never sends the caller's tree upstream: one `/search/` guarded by `readScopePattern` fetches the readable notes' contexts (plus one body fetch per batch of readable notes when the tree names `content`), `filterReadable` gates them, `keepReadable` reduces `links` / `backlinks` / `unresolvedLinks`, and `createJsonLogicEvaluation` runs the tree on a worker thread with the plugin's operators, bounded by depth, a per-note build budget, a per-search kept-result budget, and a deadline (`logic_too_costly`; a note too deep to hand to the worker is `note_too_deep`), so no hit, result, or error reads a note outside the scope. Link targets are still Obsidian's vault-wide resolution, so the reduced link fields can shift when an out-of-scope note appears or disappears (#165); docs state that bound, never a stronger one. `listTags` reads per-note tags from the same readable contexts, with no evaluation. An `active` / `periodic` target is gated on the path it resolves to, under the target's label: `resolveTarget` / `pinTarget` return a `PathTarget` whose `resolvedFrom` the asserts take, so a denial names `(active file)` or `daily note for 2026-10-01`, never a path the caller did not send, and a tool that reads a target then writes it writes to that pinned path rather than resolving the target again. Don't bypass this — `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` are the single chokepoint, and `path_forbidden` is declared on every path-taking tool's `errors[]` contract.
43
43
  - **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them.
44
44
 
45
45
  ---
@@ -48,7 +48,7 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
48
48
 
49
49
  ### Tool — `obsidian_list_tags`
50
50
 
51
- A small read-only tool that wraps a single upstream endpoint, normalizes the response into the output schema, and renders a markdown twin in `format()`. Reduced for illustration — the live definition also carries `nameRegex` / `minCount` / `limit` inputs, the count-descending sort and cap they feed, an `errors[]` contract, and an `enrichment` block.
51
+ A small read-only tool that wraps a single service call, normalizes the response into the output schema, and renders a markdown twin in `format()`. Reduced for illustration — the live definition also carries `nameRegex` / `minCount` / `limit` inputs, the count-descending sort and cap they feed, an `errors[]` contract, and an `enrichment` block.
52
52
 
53
53
  ```ts
54
54
  import { tool, z } from '@cyanheads/mcp-ts-core';
@@ -64,7 +64,11 @@ export const obsidianListTags = tool('obsidian_list_tags', {
64
64
  .array(
65
65
  z.object({
66
66
  name: z.string().describe('Tag name without the leading `#`.'),
67
- count: z.number().describe('Usage count across the vault.'),
67
+ count: z
68
+ .number()
69
+ .describe(
70
+ 'Times the tag, or a tag nested under it, occurs across the vault. When OBSIDIAN_READ_PATHS is set: the number of readable notes carrying the tag or a tag nested under it.',
71
+ ),
68
72
  }).describe('A tag with its usage count.'),
69
73
  )
70
74
  .describe('Matching tags ordered by `count` descending.'),
@@ -92,7 +96,7 @@ export const obsidianListTags = tool('obsidian_list_tags', {
92
96
  });
93
97
  ```
94
98
 
95
- For a destructive tool with human-in-the-loop confirmation, see `obsidian-delete-note.tool.ts` — it redeems a single-use consent record from `ctx.state` (bound to the operation, the caller, the resolved path, and a SHA-256 of the note's content), suspends with `ctx.requestInput` for an embedded `elicitation/create` round when no matching record backs the answer, branches on `ctx.inputs.view()` so a declined prompt on a matching round fails instead of re-asking, and carries the `destructiveHint` annotation. Pattern: framework `api-context` § Consent gates.
99
+ For a destructive tool with optional human-in-the-loop confirmation, see `obsidian-delete-note.tool.ts`. `src/index.ts` builds it with `buildDeleteNoteTool({ elicitation })` from `OBSIDIAN_DELETE_ELICITATION`, so the description states the active mode; the exported `obsidianDeleteNote` is the off build, kept as the specimen the definition linter and tests import. Both modes check write scope on the resolved path, then read the note before the DELETE. That read is the folder guard (`path_is_directory`), since the Local REST API's `DELETE` on a folder path removes the folder and everything in it. It is also the exact-case guard, so it goes through `note+json` (`getNoteJson`), never the raw-markdown read. On a case-insensitive filesystem the raw read and the plugin v4.x `DELETE` (`adapter.exists` + `adapter.remove`) case-fold, so a wrong-case path would read and then permanently remove the differently-cased note. `note+json` resolves through Obsidian's case-sensitive vault index on v4.x and v5.x, so a wrong-case path fails `note_missing` before any DELETE, with near matches listed in `suggestions` and never substituted. With the confirmation on, it redeems a single-use consent record from `ctx.state` (bound to the operation, the caller, the resolved path, and a SHA-256 of the note's content), suspends with `ctx.requestInput` for an embedded `elicitation/create` round when no matching record backs the answer, and branches on `ctx.inputs.view()` so a declined prompt on a matching round fails instead of re-asking. Only the confirming build declares `cancelled` in `errors[]`, since the off build never throws it. Both builds carry the `destructiveHint` annotation. Pattern: framework `api-context` § Consent gates.
96
100
 
97
101
  ### Resource — `obsidian://status`
98
102
 
@@ -132,11 +136,8 @@ This server exposes a CRUD/search surface; no recurring multi-turn pattern benef
132
136
  import { z } from '@cyanheads/mcp-ts-core';
133
137
  import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
134
138
 
135
- const envBoolean = z.preprocess((val) => {
136
- if (val === undefined || val === null || val === '') return;
137
- if (typeof val === 'boolean') return val;
138
- return String(val).toLowerCase().trim() === 'true' || val === '1';
139
- }, z.boolean());
139
+ /** true/false/1/0/yes/no/on/off, case-insensitive; anything else fails startup. */
140
+ const envBoolean = z.union([z.boolean(), z.stringbool()]);
140
141
 
141
142
  const ServerConfigSchema = z.object({
142
143
  apiKey: z.string().min(1).describe('Bearer token for the Obsidian Local REST API plugin.'),
@@ -148,6 +149,9 @@ const ServerConfigSchema = z.object({
148
149
  readPaths: envPathList,
149
150
  writePaths: envPathList,
150
151
  readOnly: envBoolean.default(false),
152
+ /** Opt-in delete confirmation round; when true and writes are allowed, HTTP requires a stateful session. */
153
+ deleteElicitation: envBoolean.default(false),
154
+ omnisearchUrl: z.string().url().optional(),
151
155
  });
152
156
 
153
157
  let _config: z.infer<typeof ServerConfigSchema> | undefined;
@@ -161,6 +165,8 @@ export function getServerConfig() {
161
165
  readPaths: 'OBSIDIAN_READ_PATHS',
162
166
  writePaths: 'OBSIDIAN_WRITE_PATHS',
163
167
  readOnly: 'OBSIDIAN_READ_ONLY',
168
+ deleteElicitation: 'OBSIDIAN_DELETE_ELICITATION',
169
+ omnisearchUrl: 'OBSIDIAN_OMNISEARCH_URL',
164
170
  });
165
171
  return _config;
166
172
  }
@@ -173,16 +179,20 @@ export function getServerConfig() {
173
179
  `src/index.ts` passes two `createApp()` options that shape how the server runs:
174
180
 
175
181
  ```ts
182
+ const deleteGateActive = config.deleteElicitation && !config.readOnly;
183
+
176
184
  await createApp({
177
185
  // ...
178
- sessionMode: { default: 'stateful', require: 'stateful' },
186
+ sessionMode: deleteGateActive
187
+ ? { default: 'stateful', require: 'stateful' }
188
+ : { default: 'stateful' },
179
189
  teardown: () => obsidian.close(),
180
190
  });
181
191
  ```
182
192
 
183
- `sessionMode` declares a requirement, not just a default: `obsidian_delete_note` confirms through `ctx.requestInput`, which a 2025-era HTTP client can only answer on a stateful session, so under stateless HTTP the tool would be unusable for those clients. Unset, `MCP_SESSION_MODE` runs `stateful`; when the resolved HTTP mode is `stateless` (an explicit `MCP_SESSION_MODE=stateless`), startup fails with a `ConfigurationError` naming the conflict. stdio is never refused — `MCP_SESSION_MODE` has no effect there. `tests/integration/delete-note-confirmation.test.ts` pins both.
193
+ Unset, `MCP_SESSION_MODE` runs `stateful`. With `OBSIDIAN_DELETE_ELICITATION=true`, `sessionMode` declares a requirement, not just a default: `obsidian_delete_note` then confirms through `ctx.requestInput`, which a 2025-era HTTP client can only answer on a stateful session, so under stateless HTTP the tool would be unusable for those clients. In that mode, when the resolved HTTP mode is `stateless` (an explicit `MCP_SESSION_MODE=stateless`), startup fails with a `ConfigurationError` naming the conflict. With the confirmation off (the default), or with `OBSIDIAN_READ_ONLY=true` (which disables `obsidian_delete_note`, so no round can run), nothing needs a session and stateless HTTP starts. stdio is never refused — `MCP_SESSION_MODE` has no effect there. `tests/integration/delete-note-confirmation.test.ts` pins each case. The startup "Path policy" log line reports `deleteElicitation` as `deleteGateActive` — false under read-only, as `enableCommands` is.
184
194
 
185
- The delete confirmation's consent records live in `ctx.state`, backed by the framework storage provider (`STORAGE_PROVIDER_TYPE`, default `in-memory`, process-local). That holds for stdio and for a single stateful HTTP instance, where every round of a confirmation reaches the process that asked. A multi-instance deployment, where a retry can land on another instance, needs a shared provider — `filesystem`, `supabase`, or `cloudflare-d1`, never `cloudflare-kv` (eventually consistent, so a record may be unseen or outlive its redemption). Redemption is single-use against a sequential replay but not against concurrent retries until the framework has an atomic `ctx.state.take` (cyanheads/mcp-ts-core#593).
195
+ The delete confirmation's consent records (on mode only) live in `ctx.state`, backed by the framework storage provider (`STORAGE_PROVIDER_TYPE`, default `in-memory`, process-local). That holds for stdio and for a single stateful HTTP instance, where every round of a confirmation reaches the process that asked. A multi-instance deployment, where a retry can land on another instance, needs a shared provider — `filesystem`, `supabase`, or `cloudflare-d1`, never `cloudflare-kv` (eventually consistent, so a record may be unseen or outlive its redemption). Redemption is single-use against a sequential replay but not against concurrent retries until the framework has an atomic `ctx.state.take` (cyanheads/mcp-ts-core#593).
186
196
 
187
197
  `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.
188
198
 
@@ -195,8 +205,8 @@ Handlers receive a unified `ctx` object. Properties this server actually uses:
195
205
  | Property | Description |
196
206
  |:---------|:------------|
197
207
  | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. |
198
- | `ctx.requestInput` / `ctx.inputs` | Multi-round-trip human-in-the-loop confirmation. Always present, both protocol eras — `obsidian_delete_note` requests a confirmation round before the DELETE. `requestInput` returns `never`, so write it in return position. |
199
- | `ctx.state` | Tenant-scoped KV. Used only for `obsidian_delete_note`'s consent records (`consent/<uuid>`, 600 s TTL) — see Session posture above for provider requirements. |
208
+ | `ctx.requestInput` / `ctx.inputs` | Multi-round-trip human-in-the-loop confirmation. Always present, both protocol eras — with `OBSIDIAN_DELETE_ELICITATION=true`, `obsidian_delete_note` requests a confirmation round before the DELETE; off, it reads neither. `requestInput` returns `never`, so write it in return position. |
209
+ | `ctx.state` | Tenant-scoped KV. Used only for `obsidian_delete_note`'s consent records (`consent/<uuid>`, 600 s TTL), written only with `OBSIDIAN_DELETE_ELICITATION=true` — see Session posture above for provider requirements. |
200
210
  | `ctx.auth` | Caller identity (`clientId`, `sub`) bound into each consent record; absent on stdio and `MCP_AUTH_MODE=none`, where the record carries empty strings. |
201
211
  | `ctx.signal` | `AbortSignal` propagated to the Local REST API client so per-request timeouts and client cancellations cut off in-flight HTTP. |
202
212
  | `ctx.requestId` | Unique request ID — surfaces in log lines for correlation. |
@@ -264,20 +274,26 @@ Available factories: `notFound`, `validationError`, `forbidden`, `unauthorized`,
264
274
  ```text
265
275
  src/
266
276
  index.ts # createApp() entry point — registers tools/resources, inits Obsidian service
277
+ instructions.ts # buildInstructions() — server `instructions`, effective path scope included
267
278
  config/
268
279
  server-config.ts # OBSIDIAN_* env vars (Zod schema)
269
280
  services/
270
281
  obsidian/
271
282
  obsidian-service.ts # Local REST API client (init/accessor pattern)
272
- frontmatter-ops.ts # YAML frontmatter parse/serialize/edit helpers + inline tag reader
283
+ path-policy.ts # PathPolicy — OBSIDIAN_READ_PATHS / WRITE_PATHS / READ_ONLY gate
284
+ jsonlogic-deps.d.ts # Type declarations for json-logic-js and glob-to-regexp
285
+ jsonlogic-evaluator.ts # Scoped JSONLogic evaluation: bounds, worker hand-off, logic_* errors
286
+ jsonlogic-worker.ts # Worker entry: the plugin's JSONLogic operators, hardened and budgeted
287
+ frontmatter-ops.ts # YAML frontmatter parse/serialize/edit helpers + inline tag reader + the wikilink span rule (`wikilinkCloser`) `obsidian_get_note` imports
273
288
  markdown-blocks.ts # Block structure (code, HTML, math, tables) for inline tag detection
274
289
  patch-instruction.ts # markdown-patch 1.x headers / 2.0 instructions, format negotiation, 2.0 map flattening
275
290
  section-extractor.ts # Heading/block/frontmatter section extraction
276
- types.ts # Domain types (NoteJson, NoteTarget, etc.)
291
+ types.ts # Domain types (NoteJson, NoteTarget, PathTarget, etc.)
277
292
  mcp-server/
278
293
  tools/definitions/
279
294
  _shared/schemas.ts # Shared TargetSchema + SectionSchema reused across tools
280
- index.ts # read/write/command tool sets + buildSearchNotesTool factory (Omnisearch-aware)
295
+ _shared/suggest-paths.ts # Case-mismatch fallback and suggestions for reads; names the existing note on a `case_variant_exists` write
296
+ index.ts # read/write/command tool sets + buildSearchNotesTool (Omnisearch-aware) and buildDeleteNoteTool (elicitation mode) factories
281
297
  obsidian-*.tool.ts # 14 tool definitions (12 base + 2 opt-in command-palette pair)
282
298
  resources/definitions/
283
299
  index.ts # allResourceDefinitions[]
@@ -371,6 +387,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
371
387
  | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity, `user_config` wiring, plugin manifest identity and env contract (run by devcheck) |
372
388
  | `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install |
373
389
  | `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) |
390
+ | `bun run test:coverage` | Run tests with Istanbul coverage |
374
391
  | `bun run start:stdio` | Production mode (stdio) — requires `bun run build` first |
375
392
  | `bun run start:http` | Production mode (HTTP) — requires `bun run build` first |
376
393
  | `bun run changelog:build` | Regenerate `CHANGELOG.md` rollup from `changelog/<minor>.x/*.md` |
@@ -425,6 +442,7 @@ security: false # optional — true ONLY for a source
425
442
  bun publish --access public
426
443
 
427
444
  docker buildx build --platform linux/amd64,linux/arm64 \
445
+ --build-arg APP_VERSION=<version> \
428
446
  -t ghcr.io/cyanheads/obsidian-mcp-server:<version> \
429
447
  -t ghcr.io/cyanheads/obsidian-mcp-server:latest \
430
448
  --push .
package/Dockerfile CHANGED
@@ -14,7 +14,7 @@
14
14
  # output. A stage that compiles a native addon needs the target-arch toolchain
15
15
  # and cannot cross-compile this way — drop the flag there.
16
16
  # ==============================================================================
17
- FROM --platform=$BUILDPLATFORM oven/bun:1.4.2 AS build
17
+ FROM --platform=$BUILDPLATFORM oven/bun:1.4.4 AS build
18
18
 
19
19
  WORKDIR /usr/src/app
20
20
 
@@ -46,7 +46,7 @@ RUN bun run build
46
46
  # A clean image rather than `FROM build`: the build stage's node_modules holds
47
47
  # devDependencies.
48
48
  # ==============================================================================
49
- FROM --platform=$BUILDPLATFORM oven/bun:1.4.2 AS deps
49
+ FROM --platform=$BUILDPLATFORM oven/bun:1.4.4 AS deps
50
50
 
51
51
  WORKDIR /usr/src/app
52
52
 
@@ -113,7 +113,7 @@ RUN rm -rf node_modules/@socketsecurity/bun-security-scanner
113
113
  # dependencies and build artifacts. Its only Bun invocations are HEALTHCHECK
114
114
  # and CMD, which run on the real target at container start.
115
115
  # ==============================================================================
116
- FROM oven/bun:1.4.2-slim AS production
116
+ FROM oven/bun:1.4.4-slim AS production
117
117
 
118
118
  WORKDIR /usr/src/app
119
119
 
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.1-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/)
10
+ [![Version](https://img.shields.io/badge/Version-3.7.1-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.3.1-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.4%2B-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -23,7 +23,7 @@
23
23
 
24
24
  ## Overview
25
25
 
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.
26
+ Your Obsidian vault, through the Local REST API plugin. Read, search, and write notes, edit a single heading, block, or frontmatter field in place, and manage tags, with folder-scoped read/write permissions built in.
27
27
 
28
28
  ### Tools
29
29
 
@@ -39,7 +39,7 @@ Obsidian vault notes over the Local REST API plugin. Read, search, and write not
39
39
  | `obsidian_replace_in_note` | Literal or regex search-replace inside one note, body-only by default |
40
40
  | `obsidian_manage_frontmatter` | Get, set, or delete one frontmatter key |
41
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 after the user confirms |
42
+ | `obsidian_delete_note` | Permanently delete a note, optionally after the user confirms (`OBSIDIAN_DELETE_ELICITATION`) |
43
43
  | `obsidian_open_in_ui` | Open a file in the Obsidian app, optionally in a new pane |
44
44
  | `obsidian_list_commands` | List command-palette commands (opt-in via `OBSIDIAN_ENABLE_COMMANDS`) |
45
45
  | `obsidian_execute_command` | Run a command-palette command by ID (opt-in via `OBSIDIAN_ENABLE_COMMANDS`) |
@@ -60,13 +60,15 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
60
60
 
61
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
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
63
64
 
64
65
  ---
65
66
 
66
67
  ### `obsidian_list_notes` <sub>tool</sub>
67
68
 
68
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
69
- - 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 or path policy kept out carries `truncated: true`
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`
70
72
 
71
73
  ---
72
74
 
@@ -74,12 +76,13 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
74
76
 
75
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`)
76
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
77
80
 
78
81
  ---
79
82
 
80
83
  ### `obsidian_search_notes` <sub>tool</sub>
81
84
 
82
- - `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]`
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` (plus `links`, `backlinks`, and `unresolvedLinks` on plugin versions that provide them), with `glob` / `regexp` taking `[PATTERN, VALUE]`
83
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`
84
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`
85
88
 
@@ -88,6 +91,7 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
88
91
  ### `obsidian_write_note` <sub>tool</sub>
89
92
 
90
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
+ - On a case-insensitive filesystem (macOS, Windows), a whole-file write to a path that differs from an existing note only in letter case fails with `case_variant_exists` and writes nothing; `existingPath` names that note when the parent folder's listing shows exactly one case match, and is omitted when several match or when a file Obsidian does not index (a dot-file) sits at the path
91
95
  - With `section`, replaces only that heading, block, or frontmatter field and keeps the heading line; output reports `created`, `sectionTargeted`, and the resolved `sectionTarget`
92
96
 
93
97
  ---
@@ -96,6 +100,7 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
96
100
 
97
101
  - 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
98
102
  - 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
103
+ - A whole-file append to a path that differs from an existing note only in letter case fails with `case_variant_exists` on a case-insensitive filesystem, with `existingPath` naming that note when the parent folder's listing shows exactly one case match
99
104
 
100
105
  ---
101
106
 
@@ -131,9 +136,11 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
131
136
 
132
137
  ### `obsidian_delete_note` <sub>tool</sub>
133
138
 
134
- - Takes a `target`; the first call answers with a confirmation request naming the path and byte size, and the note is deleted only after the user accepts
135
- - Declining fails with `cancelled`, and a client without elicitation support cannot delete; there is no API-level undo, only Obsidian's local trash
136
- - 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
139
+ - 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
140
+ - 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`
141
+ - 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
142
+ - 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
143
+ - 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
137
144
  - 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`)
138
145
 
139
146
  ---
@@ -142,6 +149,7 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
142
149
 
143
150
  - `path`, `failIfMissing` (default `true`), and `newLeaf` (open in a split pane); with `failIfMissing: false` a missing file is created, which needs write access
144
151
  - `createdIfMissing` reports which branch ran
152
+ - 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`
145
153
 
146
154
  ---
147
155
 
@@ -169,7 +177,8 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
169
177
 
170
178
  ### `obsidian://tags` <sub>resource</sub>
171
179
 
172
- - Every tag with its `count`, uncapped and in upstream order, hierarchical parents included
180
+ - Every tag with its `count`, uncapped and unsorted, hierarchical parents included
181
+ - With `OBSIDIAN_READ_PATHS` set, only tags from readable notes, counted the same way as `obsidian_list_tags`
173
182
  - No ranking or filters; `obsidian_list_tags` gives the count-ranked, capped view
174
183
 
175
184
  ---
@@ -188,9 +197,9 @@ Obsidian-specific:
188
197
  - 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
189
198
  - 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`)
190
199
  - 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
191
- - `obsidian_get_note` and `obsidian_open_in_ui` retry a case-mismatched path against the real filename and add `Did you mean` suggestions to a miss; writes and deletes match the exact path
200
+ - `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
192
201
  - Regex inputs are capped (`nameRegex` at 256 chars, `useRegex` at 1024) and rejected with `regex_unsafe` when they nest quantifiers
193
- - Backlinks have no dedicated tool; `obsidian_search_notes` in `jsonlogic` mode finds them with `{"regexp": ["\\[\\[Target Note(\\||#|\\]\\])", {"var": "content"}]}`
202
+ - 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]]`
194
203
 
195
204
  Agent-friendly output:
196
205
 
@@ -274,8 +283,9 @@ MCP_TRANSPORT_TYPE=http OBSIDIAN_API_KEY=... bun run start:http
274
283
  - 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`.
275
284
  - 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).
276
285
  - 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`.
286
+ - 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.
277
287
  - 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.
278
- - An MCP client that supports elicitation, to use `obsidian_delete_note`. Every other tool works without it.
288
+ - 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.
279
289
 
280
290
  ### Installation
281
291
 
@@ -314,12 +324,13 @@ MCP_TRANSPORT_TYPE=http OBSIDIAN_API_KEY=... bun run start:http
314
324
  | `OBSIDIAN_REQUEST_TIMEOUT_MS` | Per-request timeout in milliseconds. | `30000` |
315
325
  | `OBSIDIAN_ENABLE_COMMANDS` | Enable `obsidian_list_commands` and `obsidian_execute_command`. Commands are opaque and can be destructive. | `false` |
316
326
  | `OBSIDIAN_READ_PATHS` | Comma-separated folder allowlist for reads. See [Path policy](#path-policy). | unset (full vault) |
317
- | `OBSIDIAN_WRITE_PATHS` | Comma-separated folder allowlist for writes. See [Path policy](#path-policy). | unset (full vault) |
327
+ | `OBSIDIAN_WRITE_PATHS` | Comma-separated folder allowlist for writes. See [Path policy](#path-policy). | unset (the read scope, or the full vault without one) |
318
328
  | `OBSIDIAN_READ_ONLY` | Deny every write and disable the command-palette pair. | `false` |
329
+ | `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` |
319
330
  | `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 |
320
331
  | `MCP_TRANSPORT_TYPE` | Transport: `stdio` or `http`. | `stdio` |
321
332
  | `MCP_HTTP_PORT` | HTTP server port. | `3010` |
322
- | `MCP_SESSION_MODE` | HTTP session mode: `stateful` or `auto`. The server requires a stateful session because the `obsidian_delete_note` confirmation needs one on 2025-era clients, so `stateless` fails startup over HTTP. No effect on stdio. | `stateful` |
333
+ | `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` |
323
334
  | `MCP_AUTH_MODE` | Authentication: `none`, `jwt`, or `oauth`. | `none` |
324
335
  | `MCP_LOG_LEVEL` | Log level (RFC 5424). | `info` |
325
336
  | `LOGS_DIR` | Directory for log files (Node.js only). | `<project-root>/logs` |
@@ -339,9 +350,10 @@ Three optional env vars limit which vault paths the tools can touch. Unset, read
339
350
 
340
351
  - Matching is by prefix, recursive, and case-insensitive; trailing slashes are normalized. Write paths are also readable.
341
352
  - `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.
342
- - 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` shows an out-of-scope folder without walking it.
343
- - Tag listings (`obsidian_list_tags`, `obsidian://tags`) are vault-wide, so tag names (never note contents) from outside the read scope can appear.
344
- - The startup log prints the active scope.
353
+ - A denial fails with `path_forbidden`, echoing the active scope in `data.activeScope` and the recovery hint. A denial on an `active` or `periodic` target names the target (`(active file)`, `daily note for 2026-10-01`), not the note it resolved to. `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.
354
+ - Under `OBSIDIAN_READ_PATHS`, searches return readable notes only, and a JSONLogic tree never reads a note outside the scope: the server evaluates it over the readable notes, with `links`, `backlinks`, and `unresolvedLinks` holding readable entries only. Which note a link points to is still Obsidian's resolution across the whole vault, so those three fields can change when a note outside the scope is created or removed; match link text in `content` when that matters. That evaluation is bounded — 1,000 levels of nesting, a work budget per note for the values a tree builds and one per search for the results it keeps, 10 s — and a tree past a bound fails with `logic_too_costly`. A readable note whose data is nested too deep to hand to the evaluator (about 3,500 levels under Node) fails the search with `note_too_deep`. Without a read scope the tree goes to the plugin unchanged.
355
+ - 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.
356
+ - The server `instructions` state the effective scope: readable is the read paths plus the write paths, and writable is the write paths, or the read scope when only reads are restricted. The startup log prints the configured lists.
345
357
 
346
358
  ## Running the server
347
359