obsidian-mcp-server 3.7.2 → 3.9.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 (110) hide show
  1. package/AGENTS.md +20 -15
  2. package/CLAUDE.md +20 -15
  3. package/README.md +31 -19
  4. package/changelog/3.8.x/3.8.0.md +38 -0
  5. package/changelog/3.9.x/3.9.0.md +43 -0
  6. package/changelog/template.md +10 -3
  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 +30 -19
  10. package/dist/config/server-config.js.map +1 -1
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.js +27 -15
  13. package/dist/index.js.map +1 -1
  14. package/dist/instructions.d.ts +4 -3
  15. package/dist/instructions.d.ts.map +1 -1
  16. package/dist/instructions.js +23 -12
  17. package/dist/instructions.js.map +1 -1
  18. package/dist/mcp-server/resources/definitions/index.d.ts +2 -2
  19. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts +3 -2
  20. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts.map +1 -1
  21. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js +6 -5
  22. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js.map +1 -1
  23. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.d.ts +2 -2
  24. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js +2 -2
  25. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +15 -6
  27. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +24 -12
  29. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/index.d.ts +250 -230
  31. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/definitions/index.js +6 -4
  33. package/dist/mcp-server/tools/definitions/index.js.map +1 -1
  34. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +8 -2
  35. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +10 -3
  37. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
  38. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +7 -6
  39. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
  40. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +7 -7
  41. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
  42. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +2 -2
  43. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
  44. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +8 -5
  45. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
  46. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +6 -6
  47. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
  48. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +8 -8
  49. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
  50. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts +4 -4
  51. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +7 -7
  52. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
  53. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts +2 -2
  54. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -1
  55. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +2 -2
  56. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -1
  57. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts +2 -2
  58. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
  59. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +2 -2
  60. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
  61. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +2 -2
  62. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +3 -3
  63. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
  64. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +8 -2
  65. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts.map +1 -1
  66. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +10 -3
  67. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
  68. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts +2 -2
  69. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +2 -2
  70. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
  71. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +185 -35
  72. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
  73. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +145 -82
  74. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
  75. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +8 -2
  76. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
  77. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +12 -5
  78. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
  79. package/dist/services/obsidian/jsonlogic-evaluator.d.ts +33 -8
  80. package/dist/services/obsidian/jsonlogic-evaluator.d.ts.map +1 -1
  81. package/dist/services/obsidian/jsonlogic-evaluator.js +171 -32
  82. package/dist/services/obsidian/jsonlogic-evaluator.js.map +1 -1
  83. package/dist/services/obsidian/jsonlogic-worker.d.ts +6 -5
  84. package/dist/services/obsidian/jsonlogic-worker.d.ts.map +1 -1
  85. package/dist/services/obsidian/jsonlogic-worker.js +7 -5
  86. package/dist/services/obsidian/jsonlogic-worker.js.map +1 -1
  87. package/dist/services/obsidian/obsidian-service.d.ts +90 -36
  88. package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
  89. package/dist/services/obsidian/obsidian-service.js +748 -248
  90. package/dist/services/obsidian/obsidian-service.js.map +1 -1
  91. package/dist/services/obsidian/patch-instruction.d.ts +4 -1
  92. package/dist/services/obsidian/patch-instruction.d.ts.map +1 -1
  93. package/dist/services/obsidian/patch-instruction.js +5 -2
  94. package/dist/services/obsidian/patch-instruction.js.map +1 -1
  95. package/dist/services/obsidian/path-policy.d.ts +67 -23
  96. package/dist/services/obsidian/path-policy.d.ts.map +1 -1
  97. package/dist/services/obsidian/path-policy.js +183 -80
  98. package/dist/services/obsidian/path-policy.js.map +1 -1
  99. package/dist/services/obsidian/section-extractor.d.ts +62 -1
  100. package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
  101. package/dist/services/obsidian/section-extractor.js +387 -51
  102. package/dist/services/obsidian/section-extractor.js.map +1 -1
  103. package/dist/services/obsidian/simple-search.d.ts +62 -0
  104. package/dist/services/obsidian/simple-search.d.ts.map +1 -0
  105. package/dist/services/obsidian/simple-search.js +264 -0
  106. package/dist/services/obsidian/simple-search.js.map +1 -0
  107. package/manifest.json +12 -4
  108. package/package.json +9 -9
  109. package/server.json +23 -11
  110. package/skills/obsidian-guide/SKILL.md +61 -0
package/AGENTS.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # Agent Protocol
2
2
 
3
3
  **Server:** obsidian-mcp-server
4
- **Version:** 3.7.2
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.18`
6
- **Engines:** Bun ≥1.4.0, Node ≥24.0.0
4
+ **Version:** 3.9.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.22`
6
+ **Engines:** Bun ≥1.4.4, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0
8
8
  **Zod:** ^4.6.7
9
9
 
@@ -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). A scoped walk evaluates once: `pageText` and `pageJsonLogic` store the hit list a first page computed in one store (5 min past the last read, `WALK_STORE_CHARS` (32 Mi characters) across walks, least recently read evicted first), and the walk id rides `nextCursor`; a text entry is keyed by `query`, `contextLength`, and `maxMatchesPerHit` (never the scope, fixed per process, nor `pathPrefix`, applied per page) and holds copied windows, since a window sliced from a note body keeps the whole body alive. Under `restrictsReads` the `omnisearch` mode is withheld (`omnisearchAvailability`): absent from the `mode` enum, the description, and `instructions`, and nothing reaches the Omnisearch URL, the startup probe included, since Omnisearch ranks and selects its results over the whole vault. `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
 
@@ -208,7 +210,7 @@ Handlers receive a unified `ctx` object. Properties this server actually uses:
208
210
  | `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
211
  | `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. |
210
212
  | `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. |
211
- | `ctx.signal` | `AbortSignal` propagated to the Local REST API client so per-request timeouts and client cancellations cut off in-flight HTTP. |
213
+ | `ctx.signal` | `AbortSignal` propagated to the Local REST API client so client cancellations cut off in-flight HTTP; the request timer and the call budget join it per attempt. |
212
214
  | `ctx.requestId` | Unique request ID — surfaces in log lines for correlation. |
213
215
  | `ctx.tenantId` | Tenant ID from JWT or `'default'` for stdio. |
214
216
 
@@ -253,7 +255,7 @@ throw notFound(`Not found: ${display}`, data('note_missing'), { cause });
253
255
  // is non-enumerable and so reaches the log without reaching the client.
254
256
  ```
255
257
 
256
- A `fetch` that rejects before any response is classified inside the attempt (`#send`), so the retry decision sees the typed error: a refused certificate throws `ConfigurationError` `certificate_rejected` on the first attempt, an unreachable plugin throws `ServiceUnavailable` `obsidian_unreachable` and keeps the GET/PUT/DELETE retries. Both carry an inline `recovery.hint` rather than relying on the contract fill, because they reach every tool and resource — including resources with no `errors[]` — and the operator, not the agent, fixes them.
258
+ An attempt (`#send`) is the fetch and the body read together, run under a timer of `OBSIDIAN_REQUEST_TIMEOUT_MS` joined to the request's signal, the one clock both runtimes honor (Bun ignores the undici `Agent`'s timeouts, which are off). GET/PUT/DELETE retry through `withRetry`, and every request one call makes — each attempt, each backoff, each request the handler chains — shares one budget, `CALL_DEADLINE_MS` (45 s) or the timeout when that is longer, anchored per `Context` on the call's first request, so a stalled or slow plugin settles inside the 60 s an MCP client waits. A rejection is classified inside the attempt, so the retry decision sees the typed error: a refused certificate throws `ConfigurationError` `certificate_rejected` on the first attempt; an unreachable plugin, or a connection dropped mid-body, throws `ServiceUnavailable` `obsidian_unreachable` and keeps the GET/PUT/DELETE retries; the timer or the budget running out throws `Timeout` `obsidian_timeout` (a `retry_deadline_exceeded` from `withRetry` is rethrown as `obsidian_timeout`, attempt count kept, unless the last attempt failed some other typed way, which then surfaces as it is). The timer is matched by its own abort reason, never by the `TimeoutError` name, so a caller's cancellation and a scoped search's deadline pass through untouched and are never retried; a scoped search's requests take its 45 s deadline as their budget instead of the call's, so the search still fails `*_too_costly`. All three carry an inline `recovery.hint` rather than relying on the contract fill, because they reach every tool and resource — including resources with no `errors[]` — and the operator, not the agent, fixes them.
257
259
 
258
260
  **Fallback for ad-hoc throws** (no contract entry fits, prototype tools, service-layer code without a contract): use error factories.
259
261
 
@@ -263,7 +265,7 @@ throw notFound('Note not found', { path });
263
265
  throw serviceUnavailable('Local REST API unavailable', { url }, { cause: err });
264
266
  ```
265
267
 
266
- For HTTP responses from the Local REST API, use `httpErrorFromResponse(response, { service: 'obsidian-rest' })` from `/utils` — maps the full status table (401/403/408/422/429/5xx) and captures body + `Retry-After`.
268
+ Local REST API responses never go through `httpErrorFromResponse`. `#throwForStatus` reads the body (already in memory from the attempt) only to pick a reason per status branch, keeps the upstream text on `cause`, and maps every other 4xx/5xx with `httpStatusToErrorCode`, re-attaching only `retryAfter` and a 501's `retryable: false`. The helper would put the upstream's reason phrase in the message and `data.statusText`, which the containment invariant keeps off the wire.
267
269
 
268
270
  Available factories: `notFound`, `validationError`, `forbidden`, `unauthorized`, `invalidParams`, `invalidRequest`, `conflict`, `rateLimited`, `timeout`, `serviceUnavailable`, `configurationError`, `internalError`, `serializationError`, `databaseError`. Plain `Error` is also auto-classified from message patterns (`'not found'` → `NotFound`, etc.). See framework CLAUDE.md and the `api-errors` skill for the full pattern table.
269
271
 
@@ -280,14 +282,15 @@ 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
290
- section-extractor.ts # Heading/block/frontmatter section extraction
293
+ section-extractor.ts # Heading/block/frontmatter section extraction + the repeated-link-definition guard for heading writes (`droppedDefinitionInReach`)
291
294
  types.ts # Domain types (NoteJson, NoteTarget, PathTarget, etc.)
292
295
  mcp-server/
293
296
  tools/definitions/
@@ -302,6 +305,8 @@ src/
302
305
  obsidian-status.resource.ts # obsidian://status
303
306
  prompts/definitions/
304
307
  index.ts # allPromptDefinitions = [] (intentionally empty)
308
+ skills/
309
+ obsidian-guide/SKILL.md # Published skill: gates and multi-call workflows, served as skill://obsidian-guide/SKILL.md
305
310
  ```
306
311
 
307
312
  ---
@@ -324,7 +329,7 @@ Two skill trees can live at the project root, written for different readers:
324
329
  | Tree | For | Written by |
325
330
  |:-----|:----|:-----------|
326
331
  | `framework-skills/` | The agent developing this server — framework skills (`metadata.audience: external`) plus any development-time skills of the server's own | `init` and the `maintenance` sync (framework skills); you (your own) |
327
- | `skills/` | The agents that install the server and call its tools — tool chains, identifier sources, gates | You, deliberately, with the `add-skill` skill. Absent here: this server publishes none, so `createApp()` gets no `skills` option |
332
+ | `skills/` | The agents that install the server and call its tools — tool chains, identifier sources, gates | You, deliberately, with the `add-skill` skill. Here: `obsidian-guide`, served through `createApp({ skills: 'skills' })` |
328
333
 
329
334
  Development skills are modular instructions. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry. Their directory is deliberately not `skills/`: Claude Code and Codex auto-load a plugin's root `skills/`, so a server that ships `.claude-plugin/` or `.codex-plugin/` would hand them to every agent that installs it.
330
335
 
@@ -409,7 +414,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
409
414
 
410
415
  ## Bundling
411
416
 
412
- `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. `mcpb pack` reads `.mcpbignore`, not `.gitignore`, so every root path the bundle must not ship — source, tests, docs, caches, logs, agent docs, plugin manifests, gitignored local files — needs a root-anchored line there (`/docs/`, never `docs/`, which also strips `node_modules/*/docs/`); `lint:packaging` names each missing or unanchored line. Root `skills/` ships on purpose. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips four classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs (`framework-skills/`, `skills/`, `.claude/`, `.agents/`, `SKILL.md`); platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on; node-gyp build output beside a `binding.gyp`, which records the packing machine's paths (compiled `*.node` addons stay); and build-only package files — the framework's `changelog/`, `scripts/`, `templates/`, `CLAUDE.md`, `AGENTS.md`, and config bases. MCPB is stdio-only — HTTP deployments are unaffected. Delete `manifest.json` and `.mcpbignore` if not shipping MCPB bundles; `lint:packaging` skips cleanly.
417
+ `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. `mcpb pack` reads `.mcpbignore`, not `.gitignore`, so every root path the bundle must not ship — source, tests, docs, caches, logs, agent docs, plugin manifests, gitignored local files — needs a root-anchored line there (`/docs/`, never `docs/`, which also strips `node_modules/*/docs/`); `lint:packaging` names each missing or unanchored line. Root `skills/` ships on purpose. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips four classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs — `framework-skills/`, `skills/`, `.claude/`, `.agents/`, `AGENTS.md`, and `CLAUDE.md` at a package's root, and `SKILL.md` anywhere (a `skills/` deeper in a package, such as the MCP SDK's `dist/ext/skills/`, is runtime code and stays); platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on; node-gyp build output beside a `binding.gyp`, which records the packing machine's paths (compiled `*.node` addons stay); and build-only package files — the framework's `changelog/`, `scripts/`, `templates/`, and config bases. MCPB is stdio-only — HTTP deployments are unaffected. Delete `manifest.json` and `.mcpbignore` if not shipping MCPB bundles; `lint:packaging` skips cleanly.
413
418
 
414
419
  **Adding an env var requires both files:** `server.json` (registry discovery, `environmentVariables[]`) and `manifest.json` (bundle install UX, `mcp_config.env` + `user_config`). `lint:packaging` (run by `devcheck`) verifies the env var names match, that every `user_config` option is wired into `mcp_config.env` as `"X": "${user_config.X}"` (the host substitutes nothing else — `"${X}"` reaches the server as that literal string), and that an optional string option carries `"default": ""`. A user-supplied variable also goes into the plugin manifests: `.claude-plugin/plugin.json` `userConfig` + `${user_config.<option>}` in `env`, and `.codex-plugin/mcp.json` `env_vars`.
415
420
 
package/CLAUDE.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # Agent Protocol
2
2
 
3
3
  **Server:** obsidian-mcp-server
4
- **Version:** 3.7.2
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.18`
6
- **Engines:** Bun ≥1.4.0, Node ≥24.0.0
4
+ **Version:** 3.9.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.22`
6
+ **Engines:** Bun ≥1.4.4, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0
8
8
  **Zod:** ^4.6.7
9
9
 
@@ -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). A scoped walk evaluates once: `pageText` and `pageJsonLogic` store the hit list a first page computed in one store (5 min past the last read, `WALK_STORE_CHARS` (32 Mi characters) across walks, least recently read evicted first), and the walk id rides `nextCursor`; a text entry is keyed by `query`, `contextLength`, and `maxMatchesPerHit` (never the scope, fixed per process, nor `pathPrefix`, applied per page) and holds copied windows, since a window sliced from a note body keeps the whole body alive. Under `restrictsReads` the `omnisearch` mode is withheld (`omnisearchAvailability`): absent from the `mode` enum, the description, and `instructions`, and nothing reaches the Omnisearch URL, the startup probe included, since Omnisearch ranks and selects its results over the whole vault. `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
 
@@ -208,7 +210,7 @@ Handlers receive a unified `ctx` object. Properties this server actually uses:
208
210
  | `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
211
  | `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. |
210
212
  | `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. |
211
- | `ctx.signal` | `AbortSignal` propagated to the Local REST API client so per-request timeouts and client cancellations cut off in-flight HTTP. |
213
+ | `ctx.signal` | `AbortSignal` propagated to the Local REST API client so client cancellations cut off in-flight HTTP; the request timer and the call budget join it per attempt. |
212
214
  | `ctx.requestId` | Unique request ID — surfaces in log lines for correlation. |
213
215
  | `ctx.tenantId` | Tenant ID from JWT or `'default'` for stdio. |
214
216
 
@@ -253,7 +255,7 @@ throw notFound(`Not found: ${display}`, data('note_missing'), { cause });
253
255
  // is non-enumerable and so reaches the log without reaching the client.
254
256
  ```
255
257
 
256
- A `fetch` that rejects before any response is classified inside the attempt (`#send`), so the retry decision sees the typed error: a refused certificate throws `ConfigurationError` `certificate_rejected` on the first attempt, an unreachable plugin throws `ServiceUnavailable` `obsidian_unreachable` and keeps the GET/PUT/DELETE retries. Both carry an inline `recovery.hint` rather than relying on the contract fill, because they reach every tool and resource — including resources with no `errors[]` — and the operator, not the agent, fixes them.
258
+ An attempt (`#send`) is the fetch and the body read together, run under a timer of `OBSIDIAN_REQUEST_TIMEOUT_MS` joined to the request's signal, the one clock both runtimes honor (Bun ignores the undici `Agent`'s timeouts, which are off). GET/PUT/DELETE retry through `withRetry`, and every request one call makes — each attempt, each backoff, each request the handler chains — shares one budget, `CALL_DEADLINE_MS` (45 s) or the timeout when that is longer, anchored per `Context` on the call's first request, so a stalled or slow plugin settles inside the 60 s an MCP client waits. A rejection is classified inside the attempt, so the retry decision sees the typed error: a refused certificate throws `ConfigurationError` `certificate_rejected` on the first attempt; an unreachable plugin, or a connection dropped mid-body, throws `ServiceUnavailable` `obsidian_unreachable` and keeps the GET/PUT/DELETE retries; the timer or the budget running out throws `Timeout` `obsidian_timeout` (a `retry_deadline_exceeded` from `withRetry` is rethrown as `obsidian_timeout`, attempt count kept, unless the last attempt failed some other typed way, which then surfaces as it is). The timer is matched by its own abort reason, never by the `TimeoutError` name, so a caller's cancellation and a scoped search's deadline pass through untouched and are never retried; a scoped search's requests take its 45 s deadline as their budget instead of the call's, so the search still fails `*_too_costly`. All three carry an inline `recovery.hint` rather than relying on the contract fill, because they reach every tool and resource — including resources with no `errors[]` — and the operator, not the agent, fixes them.
257
259
 
258
260
  **Fallback for ad-hoc throws** (no contract entry fits, prototype tools, service-layer code without a contract): use error factories.
259
261
 
@@ -263,7 +265,7 @@ throw notFound('Note not found', { path });
263
265
  throw serviceUnavailable('Local REST API unavailable', { url }, { cause: err });
264
266
  ```
265
267
 
266
- For HTTP responses from the Local REST API, use `httpErrorFromResponse(response, { service: 'obsidian-rest' })` from `/utils` — maps the full status table (401/403/408/422/429/5xx) and captures body + `Retry-After`.
268
+ Local REST API responses never go through `httpErrorFromResponse`. `#throwForStatus` reads the body (already in memory from the attempt) only to pick a reason per status branch, keeps the upstream text on `cause`, and maps every other 4xx/5xx with `httpStatusToErrorCode`, re-attaching only `retryAfter` and a 501's `retryable: false`. The helper would put the upstream's reason phrase in the message and `data.statusText`, which the containment invariant keeps off the wire.
267
269
 
268
270
  Available factories: `notFound`, `validationError`, `forbidden`, `unauthorized`, `invalidParams`, `invalidRequest`, `conflict`, `rateLimited`, `timeout`, `serviceUnavailable`, `configurationError`, `internalError`, `serializationError`, `databaseError`. Plain `Error` is also auto-classified from message patterns (`'not found'` → `NotFound`, etc.). See framework CLAUDE.md and the `api-errors` skill for the full pattern table.
269
271
 
@@ -280,14 +282,15 @@ 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
290
- section-extractor.ts # Heading/block/frontmatter section extraction
293
+ section-extractor.ts # Heading/block/frontmatter section extraction + the repeated-link-definition guard for heading writes (`droppedDefinitionInReach`)
291
294
  types.ts # Domain types (NoteJson, NoteTarget, PathTarget, etc.)
292
295
  mcp-server/
293
296
  tools/definitions/
@@ -302,6 +305,8 @@ src/
302
305
  obsidian-status.resource.ts # obsidian://status
303
306
  prompts/definitions/
304
307
  index.ts # allPromptDefinitions = [] (intentionally empty)
308
+ skills/
309
+ obsidian-guide/SKILL.md # Published skill: gates and multi-call workflows, served as skill://obsidian-guide/SKILL.md
305
310
  ```
306
311
 
307
312
  ---
@@ -324,7 +329,7 @@ Two skill trees can live at the project root, written for different readers:
324
329
  | Tree | For | Written by |
325
330
  |:-----|:----|:-----------|
326
331
  | `framework-skills/` | The agent developing this server — framework skills (`metadata.audience: external`) plus any development-time skills of the server's own | `init` and the `maintenance` sync (framework skills); you (your own) |
327
- | `skills/` | The agents that install the server and call its tools — tool chains, identifier sources, gates | You, deliberately, with the `add-skill` skill. Absent here: this server publishes none, so `createApp()` gets no `skills` option |
332
+ | `skills/` | The agents that install the server and call its tools — tool chains, identifier sources, gates | You, deliberately, with the `add-skill` skill. Here: `obsidian-guide`, served through `createApp({ skills: 'skills' })` |
328
333
 
329
334
  Development skills are modular instructions. Read them directly when a task matches — e.g., `framework-skills/add-tool/SKILL.md` when adding a tool. `bun run list-skills` prints the full registry. Their directory is deliberately not `skills/`: Claude Code and Codex auto-load a plugin's root `skills/`, so a server that ships `.claude-plugin/` or `.codex-plugin/` would hand them to every agent that installs it.
330
335
 
@@ -409,7 +414,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
409
414
 
410
415
  ## Bundling
411
416
 
412
- `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. `mcpb pack` reads `.mcpbignore`, not `.gitignore`, so every root path the bundle must not ship — source, tests, docs, caches, logs, agent docs, plugin manifests, gitignored local files — needs a root-anchored line there (`/docs/`, never `docs/`, which also strips `node_modules/*/docs/`); `lint:packaging` names each missing or unanchored line. Root `skills/` ships on purpose. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips four classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs (`framework-skills/`, `skills/`, `.claude/`, `.agents/`, `SKILL.md`); platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on; node-gyp build output beside a `binding.gyp`, which records the packing machine's paths (compiled `*.node` addons stay); and build-only package files — the framework's `changelog/`, `scripts/`, `templates/`, `CLAUDE.md`, `AGENTS.md`, and config bases. MCPB is stdio-only — HTTP deployments are unaffected. Delete `manifest.json` and `.mcpbignore` if not shipping MCPB bundles; `lint:packaging` skips cleanly.
417
+ `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. `mcpb pack` reads `.mcpbignore`, not `.gitignore`, so every root path the bundle must not ship — source, tests, docs, caches, logs, agent docs, plugin manifests, gitignored local files — needs a root-anchored line there (`/docs/`, never `docs/`, which also strips `node_modules/*/docs/`); `lint:packaging` names each missing or unanchored line. Root `skills/` ships on purpose. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips four classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs — `framework-skills/`, `skills/`, `.claude/`, `.agents/`, `AGENTS.md`, and `CLAUDE.md` at a package's root, and `SKILL.md` anywhere (a `skills/` deeper in a package, such as the MCP SDK's `dist/ext/skills/`, is runtime code and stays); platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on; node-gyp build output beside a `binding.gyp`, which records the packing machine's paths (compiled `*.node` addons stay); and build-only package files — the framework's `changelog/`, `scripts/`, `templates/`, and config bases. MCPB is stdio-only — HTTP deployments are unaffected. Delete `manifest.json` and `.mcpbignore` if not shipping MCPB bundles; `lint:packaging` skips cleanly.
413
418
 
414
419
  **Adding an env var requires both files:** `server.json` (registry discovery, `environmentVariables[]`) and `manifest.json` (bundle install UX, `mcp_config.env` + `user_config`). `lint:packaging` (run by `devcheck`) verifies the env var names match, that every `user_config` option is wired into `mcp_config.env` as `"X": "${user_config.X}"` (the host substitutes nothing else — `"${X}"` reaches the server as that literal string), and that an optional string option carries `"default": ""`. A user-supplied variable also goes into the plugin manifests: `.claude-plugin/plugin.json` `userConfig` + `${user_config.<option>}` in `env`, and `.codex-plugin/mcp.json` `env_vars`.
415
420
 
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.9.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
 
@@ -32,7 +32,7 @@ Your Obsidian vault, through the Local REST API plugin. Read, search, and write
32
32
  | `obsidian_get_note` | Read a note as raw content, full structured form, document map, or a single section |
33
33
  | `obsidian_list_notes` | List notes and folders under a vault path, recursively, with extension and name filters |
34
34
  | `obsidian_list_tags` | List vault tags with usage counts, most-used first |
35
- | `obsidian_search_notes` | Search by text, JSONLogic, or BM25-ranked Omnisearch when that plugin is reachable |
35
+ | `obsidian_search_notes` | Search by text, JSONLogic, or BM25-ranked Omnisearch when that plugin is reachable and reads are unscoped |
36
36
  | `obsidian_write_note` | Create a note, replace one section, or overwrite a whole file with `overwrite: true` |
37
37
  | `obsidian_append_to_note` | Append to a note (creating it if missing) or to one heading, block, or frontmatter field |
38
38
  | `obsidian_patch_note` | Append, prepend, or replace against one heading, block reference, or frontmatter field |
@@ -54,6 +54,8 @@ Your Obsidian vault, through the Local REST API plugin. Read, search, and write
54
54
 
55
55
  Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidian_list_tags`); `obsidian://status` has no tool equivalent.
56
56
 
57
+ The [`obsidian-guide`](./skills/obsidian-guide/SKILL.md) skill, loaded by plugin installs and served over MCP as `skill://obsidian-guide/SKILL.md`, carries the gates and multi-call workflows no single tool describes: pinning `active` and `periodic` targets, round-tripping heading sections, paging a search before editing its hits, exact tag matching, and repeating a write after a timeout or a dropped connection.
58
+
57
59
  ## Capability reference
58
60
 
59
61
  ### `obsidian_get_note` <sub>tool</sub>
@@ -68,7 +70,7 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
68
70
 
69
71
  - 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
72
  - 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`
73
+ - 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
74
 
73
75
  ---
74
76
 
@@ -76,15 +78,15 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
76
78
 
77
79
  - `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
80
  - 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
81
+ - 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
82
 
81
83
  ---
82
84
 
83
85
  ### `obsidian_search_notes` <sub>tool</sub>
84
86
 
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]`
87
+ - `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
88
  - `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`
89
+ - `mode: "omnisearch"` (BM25 ranking, typo tolerance, PDF/OCR via Text Extractor, quoted phrases, `-exclusion`, `path:` / `ext:` filters) is offered only when the Omnisearch plugin answered at startup and neither `OBSIDIAN_READ_PATHS` nor `OBSIDIAN_DENY_PATHS` is set. 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` or `filtered_after_cap`. Omnisearch locates `matches` for its first 10 hits only, and a note it adds because it embeds a hit has `score` 0
88
90
 
89
91
  ---
90
92
 
@@ -138,7 +140,7 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
138
140
 
139
141
  - 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
142
  - 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
143
+ - 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
144
  - 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
145
  - 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
146
  - 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 +180,7 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
178
180
  ### `obsidian://tags` <sub>resource</sub>
179
181
 
180
182
  - 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`
183
+ - With `OBSIDIAN_READ_PATHS` or `OBSIDIAN_DENY_PATHS` set, only tags from readable notes, counted the same way as `obsidian_list_tags`
182
184
  - No ranking or filters; `obsidian_list_tags` gives the count-ranked, capped view
183
185
 
184
186
  ---
@@ -195,8 +197,8 @@ Built on [`@cyanheads/mcp-ts-core`](https://github.com/cyanheads/mcp-ts-core): s
195
197
  Obsidian-specific:
196
198
 
197
199
  - 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
- - 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
200
+ - 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`). A heading write is refused with `repeated_link_definition` when a link reference definition that repeats an earlier label sits above where its section ends (for the last section, above its heading), since the plugin's parser drops it and misplaces every heading below it
201
+ - 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
202
  - `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
203
  - Regex inputs are capped (`nameRegex` at 256 chars, `useRegex` at 1024) and rejected with `regex_unsafe` when they nest quantifiers
202
204
  - 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]]`
@@ -248,13 +250,14 @@ Generate the key in Obsidian under **Settings → Community Plugins → Local RE
248
250
  | `OBSIDIAN_API_KEY` | **Required.** Bearer token for the Local REST API plugin. | — |
249
251
  | `OBSIDIAN_BASE_URL` | Local REST API base URL; `https://127.0.0.1:27124` is the always-on HTTPS port. A trailing slash is stripped. When nothing answers, calls fail with `obsidian_unreachable`. | `http://127.0.0.1:27123` |
250
252
  | `OBSIDIAN_VERIFY_SSL` | Verify the plugin's TLS certificate. Off by default for its self-signed cert; the relaxation applies only to an `https:` `OBSIDIAN_BASE_URL`. With `true`, an untrusted cert fails calls with `certificate_rejected`. | `false` |
251
- | `OBSIDIAN_REQUEST_TIMEOUT_MS` | Per-request timeout in milliseconds. | `30000` |
253
+ | `OBSIDIAN_REQUEST_TIMEOUT_MS` | Milliseconds (at most 2147483647) one request attempt, headers and body, may take. `GET`, `PUT`, and `DELETE` requests retry a timed-out attempt; all the requests of one tool call share a 45 s budget (or this value, when longer), so at the default a stalled Obsidian fails the call with `obsidian_timeout` before a typical MCP client's 60 s request timeout. | `30000` |
252
254
  | `OBSIDIAN_ENABLE_COMMANDS` | Enable `obsidian_list_commands` and `obsidian_execute_command`. Commands are opaque and can be destructive. | `false` |
253
255
  | `OBSIDIAN_READ_PATHS` | Comma-separated folder allowlist for reads. See [Path policy](#path-policy). | unset (full vault) |
254
256
  | `OBSIDIAN_WRITE_PATHS` | Comma-separated folder allowlist for writes. See [Path policy](#path-policy). | unset (the read scope, or the full vault without one) |
257
+ | `OBSIDIAN_DENY_PATHS` | Comma-separated paths withheld from reads and writes alike, overriding both allowlists. See [Path policy](#path-policy). | unset (nothing denied) |
255
258
  | `OBSIDIAN_READ_ONLY` | Deny every write and disable the command-palette pair. | `false` |
256
259
  | `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
- | `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 |
260
+ | `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. Under `OBSIDIAN_READ_PATHS` or `OBSIDIAN_DENY_PATHS` it is never probed or called and the mode is withheld (see [Path policy](#path-policy)). | derived |
258
261
  | `MCP_TRANSPORT_TYPE` | Transport: `stdio` or `http`. | `stdio` |
259
262
  | `MCP_HTTP_PORT` | HTTP server port. | `3010` |
260
263
  | `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` |
@@ -267,26 +270,34 @@ See [`.env.example`](./.env.example) for the full list of optional overrides.
267
270
 
268
271
  ### Path policy
269
272
 
270
- Three optional env vars limit which vault paths the tools can touch. Unset, reads and writes cover the full vault.
273
+ Four optional env vars limit which vault paths the tools can touch. Unset, reads and writes cover the full vault.
271
274
 
272
275
  | Goal | Config |
273
276
  |:---|:---|
274
277
  | Read everywhere, write only in `projects/` and `scratch/` | `OBSIDIAN_WRITE_PATHS=projects/,scratch/` |
275
278
  | Read only `public/`, write only `public/inbox/` | `OBSIDIAN_READ_PATHS=public/`, `OBSIDIAN_WRITE_PATHS=public/inbox/` |
279
+ | Read and write everywhere except `private/` | `OBSIDIAN_DENY_PATHS=private/` |
280
+ | Read and write only `projects/`, except `projects/secret/` | `OBSIDIAN_READ_PATHS=projects/`, `OBSIDIAN_DENY_PATHS=projects/secret/` |
276
281
  | No writes anywhere | `OBSIDIAN_READ_ONLY=true` |
277
282
 
278
- - Matching is by prefix, recursive, and case-insensitive; trailing slashes are normalized. Write paths are also readable.
283
+ - 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.
284
+ - `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
285
  - `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.
286
+ - 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.
287
+ - 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:
288
+ - 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.
289
+ - 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`.
290
+ - 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.
291
+ - Paging: a scoped text or JSONLogic search reads the notes on its first page only. Its later pages come from the hits that page kept, for 5 minutes past the last page read and within 32 Mi characters across searches, so they show the vault as the first page found it; a cursor whose hits are gone, or sent with a different `query`, `contextLength`, `maxMatchesPerHit`, or tree, searches again.
292
+ - Omnisearch: the `omnisearch` mode is withheld and the plugin is never called. Omnisearch ranks and selects its results over the whole vault, so they depend on notes outside the scope. Ranking, typo tolerance, and PDF/OCR coverage are not available under a scope.
293
+ - 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.
294
+ - 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
295
 
285
296
  ## Running the server
286
297
 
287
298
  ### Local development
288
299
 
289
- Requires [Bun v1.4.0](https://bun.sh/) or higher (or Node.js v24+).
300
+ Requires [Bun v1.4.4](https://bun.sh/) or higher (or Node.js v24+).
290
301
 
291
302
  - **Clone and install:**
292
303
 
@@ -336,6 +347,7 @@ If the port is reachable from other machines, set `MCP_AUTH_MODE=jwt` (with `MCP
336
347
  | `src/mcp-server/tools` | Tool definitions (`*.tool.ts`) and shared schemas and helpers. |
337
348
  | `src/mcp-server/resources` | Resource definitions (`*.resource.ts`). |
338
349
  | `src/mcp-server/prompts` | Prompt definitions (none registered). |
350
+ | `skills/` | The `obsidian-guide` skill published to agents that install the server. |
339
351
  | `tests/` | Vitest tests for tools, resources, services, and config. |
340
352
  | `docs/` | Local REST API OpenAPI spec and the generated `tree.md`. |
341
353
  | `changelog/` | Per-version release notes; `CHANGELOG.md` is the regenerated rollup. |
@@ -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)