obsidian-mcp-server 3.7.2 → 3.8.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 (99) hide show
  1. package/AGENTS.md +10 -7
  2. package/CLAUDE.md +10 -7
  3. package/README.md +22 -14
  4. package/changelog/3.8.x/3.8.0.md +38 -0
  5. package/dist/config/server-config.d.ts +1 -0
  6. package/dist/config/server-config.d.ts.map +1 -1
  7. package/dist/config/server-config.js +24 -15
  8. package/dist/config/server-config.js.map +1 -1
  9. package/dist/index.js +6 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/instructions.d.ts.map +1 -1
  12. package/dist/instructions.js +21 -7
  13. package/dist/instructions.js.map +1 -1
  14. package/dist/mcp-server/resources/definitions/index.d.ts +2 -2
  15. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts +3 -2
  16. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts.map +1 -1
  17. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js +6 -5
  18. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js.map +1 -1
  19. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.d.ts +2 -2
  20. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js +2 -2
  21. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +8 -1
  23. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +14 -5
  25. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/index.d.ts +18 -18
  27. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +2 -2
  28. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +2 -2
  29. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +7 -6
  31. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +7 -7
  33. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
  34. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +2 -2
  35. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +8 -5
  37. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
  38. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +6 -6
  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 +8 -8
  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 +4 -4
  43. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +6 -6
  44. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
  45. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts +2 -2
  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 +2 -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.d.ts +2 -2
  50. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
  51. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +2 -2
  52. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
  53. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +2 -2
  54. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +2 -2
  55. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
  56. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +2 -2
  57. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +2 -2
  58. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
  59. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts +2 -2
  60. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +2 -2
  61. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
  62. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +46 -12
  63. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
  64. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +77 -22
  65. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
  66. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +2 -2
  67. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +2 -2
  68. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
  69. package/dist/services/obsidian/jsonlogic-evaluator.d.ts +33 -8
  70. package/dist/services/obsidian/jsonlogic-evaluator.d.ts.map +1 -1
  71. package/dist/services/obsidian/jsonlogic-evaluator.js +171 -32
  72. package/dist/services/obsidian/jsonlogic-evaluator.js.map +1 -1
  73. package/dist/services/obsidian/jsonlogic-worker.d.ts +6 -5
  74. package/dist/services/obsidian/jsonlogic-worker.d.ts.map +1 -1
  75. package/dist/services/obsidian/jsonlogic-worker.js +7 -5
  76. package/dist/services/obsidian/jsonlogic-worker.js.map +1 -1
  77. package/dist/services/obsidian/obsidian-service.d.ts +38 -22
  78. package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
  79. package/dist/services/obsidian/obsidian-service.js +293 -99
  80. package/dist/services/obsidian/obsidian-service.js.map +1 -1
  81. package/dist/services/obsidian/patch-instruction.d.ts +4 -1
  82. package/dist/services/obsidian/patch-instruction.d.ts.map +1 -1
  83. package/dist/services/obsidian/patch-instruction.js +5 -2
  84. package/dist/services/obsidian/patch-instruction.js.map +1 -1
  85. package/dist/services/obsidian/path-policy.d.ts +67 -23
  86. package/dist/services/obsidian/path-policy.d.ts.map +1 -1
  87. package/dist/services/obsidian/path-policy.js +183 -80
  88. package/dist/services/obsidian/path-policy.js.map +1 -1
  89. package/dist/services/obsidian/section-extractor.d.ts +6 -1
  90. package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
  91. package/dist/services/obsidian/section-extractor.js +106 -11
  92. package/dist/services/obsidian/section-extractor.js.map +1 -1
  93. package/dist/services/obsidian/simple-search.d.ts +62 -0
  94. package/dist/services/obsidian/simple-search.d.ts.map +1 -0
  95. package/dist/services/obsidian/simple-search.js +264 -0
  96. package/dist/services/obsidian/simple-search.js.map +1 -0
  97. package/manifest.json +10 -2
  98. package/package.json +3 -3
  99. package/server.json +19 -7
package/AGENTS.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Agent Protocol
2
2
 
3
3
  **Server:** obsidian-mcp-server
4
- **Version:** 3.7.2
4
+ **Version:** 3.8.0
5
5
  **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.18`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0
@@ -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
- - **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.
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_DENY_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; `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.
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`. `OBSIDIAN_DENY_PATHS` wins over every allow rule, and setting it turns on `restrictsReads`, so a deny list alone routes listings, tag counts, and JSONLogic through the scoped paths. Under `policy.restrictsReads`, `searchJsonLogic` never sends the caller's tree upstream: one `/search/` guarded by `readScopePattern` fetches the readable notes' contexts (plus, when the tree names `content`, one `GET /vault/{path}` per readable note through the service-wide `#bodyReads` pacer, at most `BODY_READ_CONCURRENCY` (32) in flight, evaluated in batches of at most 32 MiB), `filterReadable` gates them, and the note contexts carry no `links` / `backlinks` / `unresolvedLinks` (a tree whose fixed `var`, `missing`, or `missing_some` key names one fails `link_fields_unavailable` before any request; a computed key reads `null`). `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, a 10 s evaluation deadline, and `SCOPED_SEARCH_DEADLINE_MS` (45 s) over the whole search (`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. Text mode under `restrictsReads` never calls `/search/simple/`: one guarded `/search/` names the readable candidates, one `GET` per candidate body, and `SimpleSearch` (`simple-search.ts`, a port of Obsidian's `prepareSimpleSearch`) builds the hits and windows under the same 45 s deadline (`text_search_too_costly`; `context_length_too_large` counts the response the plugin would send for the readable matches alone, every match's offsets and framing with its window, against V8's longest string). `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_DENY_PATHS` / `OBSIDIAN_READ_ONLY` are the single chokepoint, and `path_forbidden` is declared on every path-taking tool's `errors[]` contract. A `denied_path` denial carries no `activeScope`, and no agent-facing surface (error data, `instructions`) names a deny prefix; only the startup log lists them.
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
  ---
@@ -67,7 +67,7 @@ export const obsidianListTags = tool('obsidian_list_tags', {
67
67
  count: z
68
68
  .number()
69
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.',
70
+ 'Times the tag, or a tag nested under it, occurs across the vault. When OBSIDIAN_READ_PATHS or OBSIDIAN_DENY_PATHS is set: the number of readable notes carrying the tag or a tag nested under it.',
71
71
  ),
72
72
  }).describe('A tag with its usage count.'),
73
73
  )
@@ -145,9 +145,10 @@ const ServerConfigSchema = z.object({
145
145
  verifySsl: envBoolean.default(false),
146
146
  requestTimeoutMs: z.coerce.number().int().positive().default(30_000),
147
147
  enableCommands: envBoolean.default(false),
148
- /** Path-policy allowlists — comma-separated, prefix-based, case-insensitive. Unset = full vault. */
148
+ /** Path-policy lists — comma-separated, prefix-based, case-insensitive. Allowlists unset = full vault; the deny list wins over both. */
149
149
  readPaths: envPathList,
150
150
  writePaths: envPathList,
151
+ denyPaths: envPathList,
151
152
  readOnly: envBoolean.default(false),
152
153
  /** Opt-in delete confirmation round; when true and writes are allowed, HTTP requires a stateful session. */
153
154
  deleteElicitation: envBoolean.default(false),
@@ -164,6 +165,7 @@ export function getServerConfig() {
164
165
  enableCommands: 'OBSIDIAN_ENABLE_COMMANDS',
165
166
  readPaths: 'OBSIDIAN_READ_PATHS',
166
167
  writePaths: 'OBSIDIAN_WRITE_PATHS',
168
+ denyPaths: 'OBSIDIAN_DENY_PATHS',
167
169
  readOnly: 'OBSIDIAN_READ_ONLY',
168
170
  deleteElicitation: 'OBSIDIAN_DELETE_ELICITATION',
169
171
  omnisearchUrl: 'OBSIDIAN_OMNISEARCH_URL',
@@ -194,7 +196,7 @@ Unset, `MCP_SESSION_MODE` runs `stateful`. With `OBSIDIAN_DELETE_ELICITATION=tru
194
196
 
195
197
  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).
196
198
 
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.
199
+ `teardown` closes the undici `Agent` dispatcher `ObsidianService` holds for the Local REST API and Omnisearch, releasing its keep-alive sockets, and disposes the note-body read queue, rejecting reads still waiting. It runs after the transport stops accepting requests, on every shutdown path.
198
200
 
199
201
  ---
200
202
 
@@ -280,10 +282,11 @@ src/
280
282
  services/
281
283
  obsidian/
282
284
  obsidian-service.ts # Local REST API client (init/accessor pattern)
283
- path-policy.ts # PathPolicy — OBSIDIAN_READ_PATHS / WRITE_PATHS / READ_ONLY gate
285
+ path-policy.ts # PathPolicy — OBSIDIAN_READ_PATHS / WRITE_PATHS / DENY_PATHS / READ_ONLY gate
284
286
  jsonlogic-deps.d.ts # Type declarations for json-logic-js and glob-to-regexp
285
287
  jsonlogic-evaluator.ts # Scoped JSONLogic evaluation: bounds, worker hand-off, logic_* errors
286
288
  jsonlogic-worker.ts # Worker entry: the plugin's JSONLogic operators, hardened and budgeted
289
+ simple-search.ts # In-server port of the plugin's /search/simple/ matching, for text search under a read scope
287
290
  frontmatter-ops.ts # YAML frontmatter parse/serialize/edit helpers + inline tag reader + the wikilink span rule (`wikilinkCloser`) `obsidian_get_note` imports
288
291
  markdown-blocks.ts # Block structure (code, HTML, math, tables) for inline tag detection
289
292
  patch-instruction.ts # markdown-patch 1.x headers / 2.0 instructions, format negotiation, 2.0 map flattening
package/CLAUDE.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Agent Protocol
2
2
 
3
3
  **Server:** obsidian-mcp-server
4
- **Version:** 3.7.2
4
+ **Version:** 3.8.0
5
5
  **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.18`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0
@@ -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
- - **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.
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_DENY_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; `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.
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`. `OBSIDIAN_DENY_PATHS` wins over every allow rule, and setting it turns on `restrictsReads`, so a deny list alone routes listings, tag counts, and JSONLogic through the scoped paths. Under `policy.restrictsReads`, `searchJsonLogic` never sends the caller's tree upstream: one `/search/` guarded by `readScopePattern` fetches the readable notes' contexts (plus, when the tree names `content`, one `GET /vault/{path}` per readable note through the service-wide `#bodyReads` pacer, at most `BODY_READ_CONCURRENCY` (32) in flight, evaluated in batches of at most 32 MiB), `filterReadable` gates them, and the note contexts carry no `links` / `backlinks` / `unresolvedLinks` (a tree whose fixed `var`, `missing`, or `missing_some` key names one fails `link_fields_unavailable` before any request; a computed key reads `null`). `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, a 10 s evaluation deadline, and `SCOPED_SEARCH_DEADLINE_MS` (45 s) over the whole search (`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. Text mode under `restrictsReads` never calls `/search/simple/`: one guarded `/search/` names the readable candidates, one `GET` per candidate body, and `SimpleSearch` (`simple-search.ts`, a port of Obsidian's `prepareSimpleSearch`) builds the hits and windows under the same 45 s deadline (`text_search_too_costly`; `context_length_too_large` counts the response the plugin would send for the readable matches alone, every match's offsets and framing with its window, against V8's longest string). `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_DENY_PATHS` / `OBSIDIAN_READ_ONLY` are the single chokepoint, and `path_forbidden` is declared on every path-taking tool's `errors[]` contract. A `denied_path` denial carries no `activeScope`, and no agent-facing surface (error data, `instructions`) names a deny prefix; only the startup log lists them.
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
  ---
@@ -67,7 +67,7 @@ export const obsidianListTags = tool('obsidian_list_tags', {
67
67
  count: z
68
68
  .number()
69
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.',
70
+ 'Times the tag, or a tag nested under it, occurs across the vault. When OBSIDIAN_READ_PATHS or OBSIDIAN_DENY_PATHS is set: the number of readable notes carrying the tag or a tag nested under it.',
71
71
  ),
72
72
  }).describe('A tag with its usage count.'),
73
73
  )
@@ -145,9 +145,10 @@ const ServerConfigSchema = z.object({
145
145
  verifySsl: envBoolean.default(false),
146
146
  requestTimeoutMs: z.coerce.number().int().positive().default(30_000),
147
147
  enableCommands: envBoolean.default(false),
148
- /** Path-policy allowlists — comma-separated, prefix-based, case-insensitive. Unset = full vault. */
148
+ /** Path-policy lists — comma-separated, prefix-based, case-insensitive. Allowlists unset = full vault; the deny list wins over both. */
149
149
  readPaths: envPathList,
150
150
  writePaths: envPathList,
151
+ denyPaths: envPathList,
151
152
  readOnly: envBoolean.default(false),
152
153
  /** Opt-in delete confirmation round; when true and writes are allowed, HTTP requires a stateful session. */
153
154
  deleteElicitation: envBoolean.default(false),
@@ -164,6 +165,7 @@ export function getServerConfig() {
164
165
  enableCommands: 'OBSIDIAN_ENABLE_COMMANDS',
165
166
  readPaths: 'OBSIDIAN_READ_PATHS',
166
167
  writePaths: 'OBSIDIAN_WRITE_PATHS',
168
+ denyPaths: 'OBSIDIAN_DENY_PATHS',
167
169
  readOnly: 'OBSIDIAN_READ_ONLY',
168
170
  deleteElicitation: 'OBSIDIAN_DELETE_ELICITATION',
169
171
  omnisearchUrl: 'OBSIDIAN_OMNISEARCH_URL',
@@ -194,7 +196,7 @@ Unset, `MCP_SESSION_MODE` runs `stateful`. With `OBSIDIAN_DELETE_ELICITATION=tru
194
196
 
195
197
  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).
196
198
 
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.
199
+ `teardown` closes the undici `Agent` dispatcher `ObsidianService` holds for the Local REST API and Omnisearch, releasing its keep-alive sockets, and disposes the note-body read queue, rejecting reads still waiting. It runs after the transport stops accepting requests, on every shutdown path.
198
200
 
199
201
  ---
200
202
 
@@ -280,10 +282,11 @@ src/
280
282
  services/
281
283
  obsidian/
282
284
  obsidian-service.ts # Local REST API client (init/accessor pattern)
283
- path-policy.ts # PathPolicy — OBSIDIAN_READ_PATHS / WRITE_PATHS / READ_ONLY gate
285
+ path-policy.ts # PathPolicy — OBSIDIAN_READ_PATHS / WRITE_PATHS / DENY_PATHS / READ_ONLY gate
284
286
  jsonlogic-deps.d.ts # Type declarations for json-logic-js and glob-to-regexp
285
287
  jsonlogic-evaluator.ts # Scoped JSONLogic evaluation: bounds, worker hand-off, logic_* errors
286
288
  jsonlogic-worker.ts # Worker entry: the plugin's JSONLogic operators, hardened and budgeted
289
+ simple-search.ts # In-server port of the plugin's /search/simple/ matching, for text search under a read scope
287
290
  frontmatter-ops.ts # YAML frontmatter parse/serialize/edit helpers + inline tag reader + the wikilink span rule (`wikilinkCloser`) `obsidian_get_note` imports
288
291
  markdown-blocks.ts # Block structure (code, HTML, math, tables) for inline tag detection
289
292
  patch-instruction.ts # markdown-patch 1.x headers / 2.0 instructions, format negotiation, 2.0 map flattening
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.7.2-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.4.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.4%2B-blueviolet.svg?style=flat-square)](https://bun.sh/)
10
+ [![Version](https://img.shields.io/badge/Version-3.8.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.4.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.4%2B-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -68,7 +68,7 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
68
68
 
69
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
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`
71
+ - With `OBSIDIAN_READ_PATHS` or `OBSIDIAN_DENY_PATHS` set, a listing holds only readable entries and the folders leading to the scope; those folders are walked and accepted as `path`, and a denied folder is absent at every depth
72
72
 
73
73
  ---
74
74
 
@@ -76,15 +76,15 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
76
76
 
77
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
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
79
+ - With `OBSIDIAN_READ_PATHS` or `OBSIDIAN_DENY_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
80
80
 
81
81
  ---
82
82
 
83
83
  ### `obsidian_search_notes` <sub>tool</sub>
84
84
 
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]`
85
+ - `mode: "text"` requires every space-separated 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, and not under `OBSIDIAN_READ_PATHS` or `OBSIDIAN_DENY_PATHS`), with `glob` / `regexp` taking `[PATTERN, VALUE]`
86
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`
87
+ - `mode: "omnisearch"` (BM25 ranking, quoted phrases, `-exclusion`, `path:` / `ext:` filters) is offered only when the Omnisearch plugin answered at startup. It keeps its top 50 hits; `path:`, `ext:`, and plain terms apply before that cap, quoted phrases and `-exclusion` after it. `truncated: true` marks a result the cap may have cut, with `truncatedReason` `cap_reached`, `filtered_after_cap`, or `read_scope`; under `OBSIDIAN_READ_PATHS` or `OBSIDIAN_DENY_PATHS` it is always `true`, and a hit's membership and `score` still depend on the whole vault
88
88
 
89
89
  ---
90
90
 
@@ -138,7 +138,7 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
138
138
 
139
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
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
141
+ - By default the note is deleted on the first call, with no confirmation; `OBSIDIAN_WRITE_PATHS`, `OBSIDIAN_DENY_PATHS`, and `OBSIDIAN_READ_ONLY` bound what it can reach
142
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
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
144
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`)
@@ -178,7 +178,7 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
178
178
  ### `obsidian://tags` <sub>resource</sub>
179
179
 
180
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`
181
+ - With `OBSIDIAN_READ_PATHS` or `OBSIDIAN_DENY_PATHS` set, only tags from readable notes, counted the same way as `obsidian_list_tags`
182
182
  - No ranking or filters; `obsidian_list_tags` gives the count-ranked, capped view
183
183
 
184
184
  ---
@@ -196,7 +196,7 @@ Obsidian-specific:
196
196
 
197
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
198
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`)
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
199
+ - Folder-scoped read/write permissions, a deny list that withholds paths from both, 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
200
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
201
201
  - Regex inputs are capped (`nameRegex` at 256 chars, `useRegex` at 1024) and rejected with `regex_unsafe` when they nest quantifiers
202
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]]`
@@ -252,6 +252,7 @@ Generate the key in Obsidian under **Settings → Community Plugins → Local RE
252
252
  | `OBSIDIAN_ENABLE_COMMANDS` | Enable `obsidian_list_commands` and `obsidian_execute_command`. Commands are opaque and can be destructive. | `false` |
253
253
  | `OBSIDIAN_READ_PATHS` | Comma-separated folder allowlist for reads. See [Path policy](#path-policy). | unset (full vault) |
254
254
  | `OBSIDIAN_WRITE_PATHS` | Comma-separated folder allowlist for writes. See [Path policy](#path-policy). | unset (the read scope, or the full vault without one) |
255
+ | `OBSIDIAN_DENY_PATHS` | Comma-separated paths withheld from reads and writes alike, overriding both allowlists. See [Path policy](#path-policy). | unset (nothing denied) |
255
256
  | `OBSIDIAN_READ_ONLY` | Deny every write and disable the command-palette pair. | `false` |
256
257
  | `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` |
257
258
  | `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 |
@@ -267,20 +268,27 @@ See [`.env.example`](./.env.example) for the full list of optional overrides.
267
268
 
268
269
  ### Path policy
269
270
 
270
- Three optional env vars limit which vault paths the tools can touch. Unset, reads and writes cover the full vault.
271
+ Four optional env vars limit which vault paths the tools can touch. Unset, reads and writes cover the full vault.
271
272
 
272
273
  | Goal | Config |
273
274
  |:---|:---|
274
275
  | Read everywhere, write only in `projects/` and `scratch/` | `OBSIDIAN_WRITE_PATHS=projects/,scratch/` |
275
276
  | Read only `public/`, write only `public/inbox/` | `OBSIDIAN_READ_PATHS=public/`, `OBSIDIAN_WRITE_PATHS=public/inbox/` |
277
+ | Read and write everywhere except `private/` | `OBSIDIAN_DENY_PATHS=private/` |
278
+ | Read and write only `projects/`, except `projects/secret/` | `OBSIDIAN_READ_PATHS=projects/`, `OBSIDIAN_DENY_PATHS=projects/secret/` |
276
279
  | No writes anywhere | `OBSIDIAN_READ_ONLY=true` |
277
280
 
278
- - Matching is by prefix, recursive, and case-insensitive; trailing slashes are normalized. Write paths are also readable.
281
+ - Matching is by prefix, recursive, and case-insensitive. Entries are normalized the way a path reaches the plugin: `\` becomes `/`, repeated and trailing slashes collapse, `.` segments drop, and names are compared in Unicode NFC, so `journal//therapy` and `./private` cover the folders they name. Request paths get the same slash, case, and NFC folding, so `notes//private/x.md` or an NFD spelling of a denied name is refused like the plain one. An entry naming the vault root (`.`, `./`) fails startup. Write paths are also readable.
282
+ - `OBSIDIAN_DENY_PATHS` wins over both allowlists, write-implies-read included: a denied path is neither readable nor writable, and it is left out of listings, search hits, and tag counts. An allow entry inside a deny entry grants nothing, and the startup log warns about it.
279
283
  - `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.
280
- - 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.
281
- - 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.
282
- - 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.
283
- - 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.
284
+ - A denial fails with `path_forbidden`, echoing the active scope in `data.activeScope` and the recovery hint. A path the deny list withholds fails with subreason `denied_path` instead, which carries no `activeScope` and names `OBSIDIAN_DENY_PATHS` but none of its entries; a path outside an allowlist reports `outside_read_paths` or `outside_write_paths` even when it is also denied. 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; a denied folder never appears, even on the way to an allow entry.
285
+ - Under `OBSIDIAN_READ_PATHS` or `OBSIDIAN_DENY_PATHS`, searches return readable notes only. With neither set, a JSONLogic tree goes to the plugin unchanged; with either:
286
+ - JSONLogic: the server evaluates the tree over the readable notes, so it never reads a note outside the scope. Notes carry no `links`, `backlinks`, or `unresolvedLinks`, since Obsidian resolves a link across the whole vault: a tree naming one by a fixed `var`, `missing`, or `missing_some` key fails with `link_fields_unavailable`, and a computed key reads `null`. Match link text in `content` instead. A tree that names `content` reads each readable note with one request per note, at most 32 in flight across the server.
287
+ - Evaluation is bounded (1,000 levels of nesting, a work budget per note and per search, 10 s of evaluation, 45 s for the whole search); a tree past a bound fails with `logic_too_costly`. A readable note nested too deep for the evaluator (about 3,500 levels under Node) fails the search with `note_too_deep`.
288
+ - Text: the server never calls the plugin's text search. It asks the plugin which readable notes may hold every token, reads each candidate note, and applies Obsidian's search rule itself, so hits and context windows match the unscoped search. Past 45 s it fails with `text_search_too_costly`, and `context_length_too_large` fails where the plugin's response for the readable matches alone would pass its size ceiling.
289
+ - Omnisearch ranks the whole vault before the scope filters it (see `obsidian_search_notes`).
290
+ - Tag listings (`obsidian_list_tags`, `obsidian://tags`) under `OBSIDIAN_READ_PATHS` or `OBSIDIAN_DENY_PATHS` cover readable notes only, counting notes rather than occurrences. Write paths and read-only alone leave them vault-wide.
291
+ - 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. With a deny list set, each scope reads "…, except paths the operator withholds", naming none of them, and an empty scope reads `none`. The startup log prints the configured lists, the deny list included.
284
292
 
285
293
  ## Running the server
286
294
 
@@ -0,0 +1,38 @@
1
+ ---
2
+ summary: "OBSIDIAN_DENY_PATHS withholds paths from reads and writes. Under a read scope, text and JSONLogic searches now read only readable notes, so out-of-scope notes no longer change their errors or results; a scoped JSONLogic tree naming link fields by a fixed var, missing, or missing_some key is rejected. Fixes the .mcpb bundle failing to boot."
3
+ breaking: true
4
+ security: true
5
+ ---
6
+
7
+ # 3.8.0 — 2026-10-30
8
+
9
+ ## Added
10
+
11
+ - **`OBSIDIAN_DENY_PATHS`** ([#76](https://github.com/cyanheads/obsidian-mcp-server/issues/76)): a deny list checked before the allow lists on reads and writes, so it wins over `OBSIDIAN_WRITE_PATHS`' write-implies-read. Denied paths drop out of listings, search results, and tag counts; the `path_forbidden` `denied_path` denial and the server instructions never echo the entries.
12
+ - **`obsidian_search_notes` omnisearch output field `truncatedReason`** (`cap_reached | filtered_after_cap | read_scope`), present when `truncated` is true ([#172](https://github.com/cyanheads/obsidian-mcp-server/issues/172)).
13
+
14
+ ## Changed
15
+
16
+ - **`obsidian_search_notes` `jsonlogic` under `OBSIDIAN_READ_PATHS` or `OBSIDIAN_DENY_PATHS` rejects a tree that names `links`, `backlinks`, or `unresolvedLinks` by a fixed `var`, `missing`, or `missing_some` key**, with `link_fields_unavailable` and no upstream request: scoped note contexts no longer carry link fields, because Obsidian resolves them against the whole vault. A computed key reads `null`. Unscoped searches are unchanged. ([#165](https://github.com/cyanheads/obsidian-mcp-server/issues/165))
17
+ - **Omnisearch `truncated` is also true when quoted or `-exclusion` terms filter after the 50-hit cap**, and always true under a read scope ([#172](https://github.com/cyanheads/obsidian-mcp-server/issues/172)).
18
+ - **Unscoped `text` queries split on spaces, as Obsidian does.** A tab or newline stays inside its token, so a query carrying whitespace-only tokens can answer differently.
19
+ - **`OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` entries are normalized** (backslashes as slashes, repeated slashes and `.` segments dropped, Unicode NFC), and `.` or `./` entries now fail startup ([#76](https://github.com/cyanheads/obsidian-mcp-server/issues/76)).
20
+ - **Scoped `jsonlogic` that reads note content issues one `GET` per readable note**, at most 32 in flight across the server, in batches of at most 32 MiB. Each search gets one evaluation worker and a 45 s deadline (`logic_too_costly`). ([#167](https://github.com/cyanheads/obsidian-mcp-server/issues/167))
21
+ - **Scoped `text` search runs under a 45 s deadline (`text_search_too_costly`)**, and its `context_length_too_large` fails exactly where the plugin's response for the readable matches alone would pass V8's string ceiling. Each match costs its window plus about 150 characters of framing, so a very common token fails even at `contextLength` 0.
22
+
23
+ ## Fixed
24
+
25
+ - **The section scan no longer overflows the stack, or goes quadratic, on deeply nested lists or lazy blockquotes** ([#164](https://github.com/cyanheads/obsidian-mcp-server/issues/164)).
26
+ - **A duplicate link reference definition no longer shifts later heading offsets in section reads** ([#171](https://github.com/cyanheads/obsidian-mcp-server/issues/171)).
27
+ - **`obsidian_get_note` puts a blank line between the `**Content**` / `**Value:**` label and the raw body** ([#169](https://github.com/cyanheads/obsidian-mcp-server/issues/169)).
28
+ - **End-anchored trims in `canonicalContent`, `extractHeading`, the path slash trims, and the not-found suggestion builder run in linear time** ([#174](https://github.com/cyanheads/obsidian-mcp-server/issues/174)).
29
+ - **The `.mcpb` bundle boots again.** `scripts/clean-mcpb.ts` no longer strips the MCP SDK's `dist/ext/skills/`, which the server imports at startup, and `scripts/lint-packaging.ts` no longer flags it; the published 3.7.2 asset fails to boot without it.
30
+
31
+ ## Security
32
+
33
+ - **Under `OBSIDIAN_READ_PATHS`, text-mode errors, omnisearch truncation, and JSONLogic link fields varied with notes outside the scope** ([#163](https://github.com/cyanheads/obsidian-mcp-server/issues/163), [#165](https://github.com/cyanheads/obsidian-mcp-server/issues/165)). Scoped text search now runs in-server (`src/services/obsidian/simple-search.ts`, a port of the plugin's matching) with no `/search/simple/` request; scoped omnisearch is always `truncated`, and scoped JSONLogic notes carry no link fields.
34
+
35
+ ## Dependencies
36
+
37
+ - `@biomejs/biome` ^2.6.3 → ^2.6.4 (dev)
38
+ - `@types/node` ^26.6.5 → ^26.6.6 (dev)
@@ -12,6 +12,7 @@ declare const ServerConfigSchema: z.ZodObject<{
12
12
  enableCommands: z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodCodec<z.ZodString, z.ZodBoolean>]>>;
13
13
  readPaths: z.ZodPreprocess<z.ZodPipe<z.ZodOptional<z.ZodArray<z.ZodString>>, z.ZodTransform<string[] | undefined, string[] | undefined>>, unknown>;
14
14
  writePaths: z.ZodPreprocess<z.ZodPipe<z.ZodOptional<z.ZodArray<z.ZodString>>, z.ZodTransform<string[] | undefined, string[] | undefined>>, unknown>;
15
+ denyPaths: z.ZodPreprocess<z.ZodPipe<z.ZodOptional<z.ZodArray<z.ZodString>>, z.ZodTransform<string[] | undefined, string[] | undefined>>, unknown>;
15
16
  readOnly: z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodCodec<z.ZodString, z.ZodBoolean>]>>;
16
17
  deleteElicitation: z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodCodec<z.ZodString, z.ZodBoolean>]>>;
17
18
  omnisearchUrl: z.ZodOptional<z.ZodString>;
@@ -1 +1 @@
1
- {"version":3,"file":"server-config.d.ts","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAgF3C,QAAA,MAAM,kBAAkB;;;;;;;;;;;iBA4DtB,CAAC;AAEH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAI9D,wBAAgB,eAAe,IAAI,YAAY,CAc9C;AAED,iEAAiE;AACjE,wBAAgB,iBAAiB,IAAI,IAAI,CAExC"}
1
+ {"version":3,"file":"server-config.d.ts","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAwF3C,QAAA,MAAM,kBAAkB;;;;;;;;;;;;iBA+DtB,CAAC;AAEH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAI9D,wBAAgB,eAAe,IAAI,YAAY,CAe9C;AAED,iEAAiE;AACjE,wBAAgB,iBAAiB,IAAI,IAAI,CAExC"}
@@ -12,18 +12,20 @@ import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
12
12
  */
13
13
  const envBoolean = z.union([z.boolean(), z.stringbool()]);
14
14
  /**
15
- * Comma-separated path-list preprocessor. Semantics (see issue #40):
16
- * - undefined / `''` / whitespace-only → undefined (treat as unset; default = full vault)
15
+ * Comma-separated path-list preprocessor, shared by OBSIDIAN_READ_PATHS,
16
+ * OBSIDIAN_WRITE_PATHS, and OBSIDIAN_DENY_PATHS. Semantics (see issues #40, #76):
17
+ * - undefined / `''` / whitespace-only → undefined (treat as unset)
17
18
  * - `,` / `,,,` (separators only, no path content) → throw ZodError → ConfigurationError
18
19
  * - mixed empties (`a,,b`) → drop empties → `['a', 'b']`
19
- * - absolute path / `..` traversal → throw
20
- * - valid: `\` → `/` + lower-case + trim trailing slash + dedupe (preserves first occurrence order)
20
+ * - absolute path / `..` traversal / an entry naming the vault root (`.`, `./`) → throw
21
+ * - valid: `\` → `/`, empty and `.` segments dropped, lower-case, NFC, dedupe
22
+ * (preserves first occurrence order)
21
23
  *
22
- * Separator normalization is required for parity with `PathPolicy.normalize()`,
23
- * which also collapses `\` → `/` on candidate paths. Without matching
24
- * normalization here, a prefix like `Foo\Bar` would never match a candidate
25
- * `Foo\Bar\note.md` (candidate becomes `foo/bar/note.md`, prefix stays as
26
- * `foo\bar`).
24
+ * The stored form is `PathPolicy.normalize()`'s — the form a vault path
25
+ * reaches the plugin in — so a prefix and a candidate compare directly. A
26
+ * prefix kept as written (`foo\bar`, `./private`, `journal//therapy`) would
27
+ * match no vault path: a refusal for an allowlist, and a deny entry that
28
+ * silently denies nothing.
27
29
  */
28
30
  const envPathList = z
29
31
  .preprocess((val) => {
@@ -63,7 +65,7 @@ const envPathList = z
63
65
  });
64
66
  return z.NEVER;
65
67
  }
66
- const segments = raw.split(/[\\/]/);
68
+ const segments = raw.split(/[\\/]/).filter((s) => s !== '' && s !== '.');
67
69
  if (segments.includes('..')) {
68
70
  ctx.addIssue({
69
71
  code: 'custom',
@@ -71,9 +73,14 @@ const envPathList = z
71
73
  });
72
74
  return z.NEVER;
73
75
  }
74
- const normalized = raw.replace(/\\/g, '/').toLowerCase().replace(/\/+$/, '');
75
- if (normalized.length === 0)
76
- continue;
76
+ if (segments.length === 0) {
77
+ ctx.addIssue({
78
+ code: 'custom',
79
+ message: `must name a folder or note, not the vault root; got '${raw}'`,
80
+ });
81
+ return z.NEVER;
82
+ }
83
+ const normalized = segments.join('/').toLowerCase().normalize('NFC');
77
84
  if (seen.has(normalized))
78
85
  continue;
79
86
  seen.add(normalized);
@@ -111,8 +118,9 @@ const ServerConfigSchema = z.object({
111
118
  enableCommands: envBoolean
112
119
  .default(false)
113
120
  .describe('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.'),
114
- readPaths: envPathList.describe('Optional vault-relative folder allowlist for read operations. Comma-separated; prefix-based with implicit recursion; case-insensitive; trailing slashes normalized. Unset = full vault.'),
115
- writePaths: envPathList.describe('Optional vault-relative folder allowlist for write operations. Same syntax as OBSIDIAN_READ_PATHS. Write paths are implicitly readable. Unset = full vault, or the read scope when reads are restricted.'),
121
+ readPaths: envPathList.describe('Optional vault-relative folder allowlist for read operations. Comma-separated; prefix-based with implicit recursion; case-insensitive; backslashes read as slashes, repeated and trailing slashes and `.` segments dropped, and names normalized to Unicode NFC; an entry naming the vault root (`.`) is rejected at startup. Unset = full vault.'),
122
+ writePaths: envPathList.describe('Optional vault-relative folder allowlist for write operations. Same syntax as OBSIDIAN_READ_PATHS. Write paths are implicitly readable. Unset = full vault, or the read scope when reads are restricted; paths in OBSIDIAN_DENY_PATHS stay denied.'),
123
+ denyPaths: envPathList.describe('Optional vault-relative blocklist for reads and writes alike. Same syntax as OBSIDIAN_READ_PATHS. Wins over both allowlists: a denied path is unreadable and unwritable, and absent from listings, search results, and tag counts. Unset = nothing denied.'),
116
124
  readOnly: envBoolean
117
125
  .default(false)
118
126
  .describe('Global kill switch. When true, denies every write regardless of OBSIDIAN_WRITE_PATHS, and suppresses the OBSIDIAN_ENABLE_COMMANDS pair (commands can mutate). Defaults to false.'),
@@ -135,6 +143,7 @@ export function getServerConfig() {
135
143
  enableCommands: 'OBSIDIAN_ENABLE_COMMANDS',
136
144
  readPaths: 'OBSIDIAN_READ_PATHS',
137
145
  writePaths: 'OBSIDIAN_WRITE_PATHS',
146
+ denyPaths: 'OBSIDIAN_DENY_PATHS',
138
147
  readOnly: 'OBSIDIAN_READ_ONLY',
139
148
  deleteElicitation: 'OBSIDIAN_DELETE_ELICITATION',
140
149
  omnisearchUrl: 'OBSIDIAN_OMNISEARCH_URL',
@@ -1 +1 @@
1
- {"version":3,"file":"server-config.js","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"server-config.js","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -92,7 +92,9 @@ const { services } = await createApp({
92
92
  * the tool is disabled there). The server `instructions` and the active scope
93
93
  * on `path_forbidden` errors state the effective scope instead (write paths
94
94
  * are readable; a write needs read access), so the LLM (or operator) can
95
- * self-correct without scrolling the log.
95
+ * self-correct without scrolling the log. The deny list (`denyPaths`, present
96
+ * when set) appears only here: the agent-facing surfaces say paths are
97
+ * withheld without naming them.
96
98
  */
97
99
  const bannerCtx = requestContextService.createRequestContext({
98
100
  operation: 'startup',
@@ -111,4 +113,7 @@ services.logger.info(omnisearchReachable
111
113
  if (policy.readOnlyShadowsWritePaths) {
112
114
  services.logger.warning('OBSIDIAN_WRITE_PATHS is set but ignored because OBSIDIAN_READ_ONLY=true. Unset one of the two to remove the conflict.', bannerCtx);
113
115
  }
116
+ for (const { variable, prefix } of policy.denyShadowedPaths) {
117
+ services.logger.warning(`${variable} entry '${prefix}' lies inside OBSIDIAN_DENY_PATHS and grants nothing. Remove it, or narrow the deny entry that covers it.`, bannerCtx);
118
+ }
114
119
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"instructions.d.ts","sourceRoot":"","sources":["../src/instructions.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAarE;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,cAAc,EAAE,OAAO,CAAC;IACxB,mBAAmB,EAAE,OAAO,CAAC;IAC7B,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;CACnB,GAAG,MAAM,CA6BT"}
1
+ {"version":3,"file":"instructions.d.ts","sourceRoot":"","sources":["../src/instructions.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAsBrE;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,cAAc,EAAE,OAAO,CAAC;IACxB,mBAAmB,EAAE,OAAO,CAAC;IAC7B,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;CACnB,GAAG,MAAM,CAmCT"}
@@ -7,9 +7,18 @@
7
7
  */
8
8
  const BASELINE = 'Use the `obsidian_*` tools to access the Obsidian vault via the Local REST API plugin: search, read, write, and patch notes, including targeted edits to headings, blocks, and YAML frontmatter. Notes are addressed by vault-relative path including the file extension (e.g. `Folder/Note.md`); tags support hierarchical `parent/child` notation, and counts roll up to parents.';
9
9
  const SCOPE_RECOVERY = 'Paths outside scope reject with `path_forbidden` — error data carries the active scope so you can self-correct.';
10
- /** Prefixes as the instructions quote them; `undefined` is an unrestricted op. */
11
- function renderScope(scope) {
12
- return scope === undefined ? 'full vault' : scope.map((p) => `'${p}'`).join(', ');
10
+ const WITHHELD_RECOVERY = 'Withheld paths reject with subreason `denied_path` and carry no scope.';
11
+ /**
12
+ * Prefixes as the instructions quote them; `undefined` is an unrestricted op,
13
+ * `[]` an op that reaches nothing. `withheld` notes that OBSIDIAN_DENY_PATHS
14
+ * carves paths out of the scope, without naming them: the deny prefixes name
15
+ * exactly what the list hides.
16
+ */
17
+ function renderScope(scope, withheld) {
18
+ if (scope?.length === 0)
19
+ return 'none';
20
+ const reach = scope === undefined ? 'full vault' : scope.map((p) => `'${p}'`).join(', ');
21
+ return withheld ? `${reach}, except paths the operator withholds` : reach;
13
22
  }
14
23
  /**
15
24
  * Build the instructions. The scope lines state what `path_forbidden`
@@ -19,21 +28,26 @@ function renderScope(scope) {
19
28
  export function buildInstructions(opts) {
20
29
  const { enableCommands, omnisearchReachable, policy, readOnly } = opts;
21
30
  const { readable, writable } = policy.effectiveScope();
31
+ const withheld = policy.withholdsPaths;
32
+ const recovery = withheld ? `${SCOPE_RECOVERY} ${WITHHELD_RECOVERY}` : SCOPE_RECOVERY;
22
33
  const sections = [BASELINE];
23
34
  if (readOnly) {
24
35
  sections.push('Read-only mode is active (OBSIDIAN_READ_ONLY=true): write tools are unavailable. `obsidian_open_in_ui` still opens notes that exist, but rejects an open against a missing path — Obsidian would create the file.');
25
- if (readable !== undefined) {
26
- sections.push(`Vault path policy is enforced. Readable: ${renderScope(readable)}. ${SCOPE_RECOVERY}`);
36
+ if (readable !== undefined || withheld) {
37
+ sections.push(`Vault path policy is enforced. Readable: ${renderScope(readable, withheld)}. ${recovery}`);
27
38
  }
28
39
  }
29
40
  else if (!policy.isUnrestricted) {
30
- sections.push(`Vault path policy is enforced. Readable: ${renderScope(readable)}. Writable: ${renderScope(writable)}. ${SCOPE_RECOVERY}`);
41
+ sections.push(`Vault path policy is enforced. Readable: ${renderScope(readable, withheld)}. Writable: ${renderScope(writable, withheld)}. ${recovery}`);
31
42
  }
32
43
  if (enableCommands && !readOnly) {
33
44
  sections.push('Command-palette tools (`obsidian_list_commands`, `obsidian_execute_command`) are enabled and can fire any Obsidian command. Commands are opaque and may be destructive — prefer dedicated tools when one fits.');
34
45
  }
35
46
  if (omnisearchReachable) {
36
- sections.push('`obsidian_search_notes` includes an `omnisearch` mode: BM25-ranked, typo-tolerant, with PDF/OCR coverage (via the Text Extractor plugin). Results cap at 50 upstream — narrow the query (quoted phrases, `-exclusion`, `path:` / `ext:` filters) to surface more.');
47
+ const omnisearch = '`obsidian_search_notes` includes an `omnisearch` mode: BM25-ranked, typo-tolerant, with PDF/OCR coverage (via the Text Extractor plugin). Omnisearch keeps only its top 50 matches: narrow with `path:` / `ext:` filters or more plain terms, which apply before that cap. Quoted phrases and `-exclusion` filter only those 50, so a result using them is marked `truncated`.';
48
+ sections.push(policy.restrictsReads
49
+ ? `${omnisearch} Under the read scope the cap ranks the whole vault before the scope filters it, so \`truncated\` is always true; a \`path:\` term naming a readable folder ranks within that folder.`
50
+ : omnisearch);
37
51
  }
38
52
  return sections.join('\n\n');
39
53
  }
@@ -1 +1 @@
1
- {"version":3,"file":"instructions.js","sourceRoot":"","sources":["../src/instructions.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,MAAM,QAAQ,GACZ,qXAAqX,CAAC;AAExX,MAAM,cAAc,GAClB,iHAAiH,CAAC;AAEpH,kFAAkF;AAClF,SAAS,WAAW,CAAC,KAAoC;IACvD,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACpF,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAKjC;IACC,MAAM,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC;IACvE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,MAAM,CAAC,cAAc,EAAE,CAAC;IACvD,MAAM,QAAQ,GAAG,CAAC,QAAQ,CAAC,CAAC;IAC5B,IAAI,QAAQ,EAAE,CAAC;QACb,QAAQ,CAAC,IAAI,CACX,mNAAmN,CACpN,CAAC;QACF,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;YAC3B,QAAQ,CAAC,IAAI,CACX,4CAA4C,WAAW,CAAC,QAAQ,CAAC,KAAK,cAAc,EAAE,CACvF,CAAC;QACJ,CAAC;IACH,CAAC;SAAM,IAAI,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC;QAClC,QAAQ,CAAC,IAAI,CACX,4CAA4C,WAAW,CAAC,QAAQ,CAAC,eAAe,WAAW,CAAC,QAAQ,CAAC,KAAK,cAAc,EAAE,CAC3H,CAAC;IACJ,CAAC;IACD,IAAI,cAAc,IAAI,CAAC,QAAQ,EAAE,CAAC;QAChC,QAAQ,CAAC,IAAI,CACX,gNAAgN,CACjN,CAAC;IACJ,CAAC;IACD,IAAI,mBAAmB,EAAE,CAAC;QACxB,QAAQ,CAAC,IAAI,CACX,mQAAmQ,CACpQ,CAAC;IACJ,CAAC;IACD,OAAO,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAC/B,CAAC"}
1
+ {"version":3,"file":"instructions.js","sourceRoot":"","sources":["../src/instructions.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,MAAM,QAAQ,GACZ,qXAAqX,CAAC;AAExX,MAAM,cAAc,GAClB,iHAAiH,CAAC;AAEpH,MAAM,iBAAiB,GAAG,wEAAwE,CAAC;AAEnG;;;;;GAKG;AACH,SAAS,WAAW,CAAC,KAAoC,EAAE,QAAiB;IAC1E,IAAI,KAAK,EAAE,MAAM,KAAK,CAAC;QAAE,OAAO,MAAM,CAAC;IACvC,MAAM,KAAK,GAAG,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACzF,OAAO,QAAQ,CAAC,CAAC,CAAC,GAAG,KAAK,uCAAuC,CAAC,CAAC,CAAC,KAAK,CAAC;AAC5E,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAKjC;IACC,MAAM,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC;IACvE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,MAAM,CAAC,cAAc,EAAE,CAAC;IACvD,MAAM,QAAQ,GAAG,MAAM,CAAC,cAAc,CAAC;IACvC,MAAM,QAAQ,GAAG,QAAQ,CAAC,CAAC,CAAC,GAAG,cAAc,IAAI,iBAAiB,EAAE,CAAC,CAAC,CAAC,cAAc,CAAC;IACtF,MAAM,QAAQ,GAAG,CAAC,QAAQ,CAAC,CAAC;IAC5B,IAAI,QAAQ,EAAE,CAAC;QACb,QAAQ,CAAC,IAAI,CACX,mNAAmN,CACpN,CAAC;QACF,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,EAAE,CAAC;YACvC,QAAQ,CAAC,IAAI,CACX,4CAA4C,WAAW,CAAC,QAAQ,EAAE,QAAQ,CAAC,KAAK,QAAQ,EAAE,CAC3F,CAAC;QACJ,CAAC;IACH,CAAC;SAAM,IAAI,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC;QAClC,QAAQ,CAAC,IAAI,CACX,4CAA4C,WAAW,CAAC,QAAQ,EAAE,QAAQ,CAAC,eAAe,WAAW,CAAC,QAAQ,EAAE,QAAQ,CAAC,KAAK,QAAQ,EAAE,CACzI,CAAC;IACJ,CAAC;IACD,IAAI,cAAc,IAAI,CAAC,QAAQ,EAAE,CAAC;QAChC,QAAQ,CAAC,IAAI,CACX,gNAAgN,CACjN,CAAC;IACJ,CAAC;IACD,IAAI,mBAAmB,EAAE,CAAC;QACxB,MAAM,UAAU,GACd,gXAAgX,CAAC;QACnX,QAAQ,CAAC,IAAI,CACX,MAAM,CAAC,cAAc;YACnB,CAAC,CAAC,GAAG,UAAU,uLAAuL;YACtM,CAAC,CAAC,UAAU,CACf,CAAC;IACJ,CAAC;IACD,OAAO,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAC/B,CAAC"}
@@ -17,8 +17,8 @@ export declare const allResourceDefinitions: (import("@cyanheads/mcp-ts-core").R
17
17
  }, import("zod/v4/core").$strip>, readonly [{
18
18
  readonly reason: "path_forbidden";
19
19
  readonly code: import("@cyanheads/mcp-ts-core/errors").JsonRpcErrorCode.Forbidden;
20
- readonly when: "The requested path is outside OBSIDIAN_READ_PATHS (and OBSIDIAN_WRITE_PATHS, since write paths imply read access).";
21
- readonly recovery: "Use a path inside the configured read scope. The error data echoes the active scope.";
20
+ readonly when: "The requested path is outside OBSIDIAN_READ_PATHS (and OBSIDIAN_WRITE_PATHS, since write paths imply read access), or inside OBSIDIAN_DENY_PATHS.";
21
+ readonly recovery: "Use a path inside the configured read scope. The error data echoes the active scope; a `denied_path` denial carries none, since the operator withholds that path.";
22
22
  }, {
23
23
  readonly reason: "note_missing";
24
24
  readonly code: import("@cyanheads/mcp-ts-core/errors").JsonRpcErrorCode.NotFound;