obsidian-mcp-server 3.7.1 → 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 (117) hide show
  1. package/AGENTS.md +28 -13
  2. package/CLAUDE.md +28 -13
  3. package/Dockerfile +7 -0
  4. package/README.md +43 -99
  5. package/changelog/3.7.x/3.7.2.md +34 -0
  6. package/changelog/3.8.x/3.8.0.md +38 -0
  7. package/dist/config/server-config.d.ts +1 -0
  8. package/dist/config/server-config.d.ts.map +1 -1
  9. package/dist/config/server-config.js +24 -15
  10. package/dist/config/server-config.js.map +1 -1
  11. package/dist/index.js +6 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/instructions.d.ts.map +1 -1
  14. package/dist/instructions.js +21 -7
  15. package/dist/instructions.js.map +1 -1
  16. package/dist/mcp-server/resources/definitions/index.d.ts +2 -2
  17. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts +3 -2
  18. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts.map +1 -1
  19. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js +6 -5
  20. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js.map +1 -1
  21. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.d.ts +2 -2
  22. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js +2 -2
  23. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js.map +1 -1
  24. package/dist/mcp-server/tools/definitions/_shared/markdown.d.ts +14 -0
  25. package/dist/mcp-server/tools/definitions/_shared/markdown.d.ts.map +1 -0
  26. package/dist/mcp-server/tools/definitions/_shared/markdown.js +17 -0
  27. package/dist/mcp-server/tools/definitions/_shared/markdown.js.map +1 -0
  28. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +8 -1
  29. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
  30. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +17 -6
  31. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
  32. package/dist/mcp-server/tools/definitions/index.d.ts +18 -18
  33. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +2 -2
  34. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -1
  35. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +5 -4
  36. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
  37. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +7 -6
  38. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
  39. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +9 -8
  40. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
  41. package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.d.ts.map +1 -1
  42. package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.js +2 -1
  43. package/dist/mcp-server/tools/definitions/obsidian-execute-command.tool.js.map +1 -1
  44. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +2 -2
  45. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
  46. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +38 -28
  47. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
  48. package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.d.ts.map +1 -1
  49. package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.js +7 -4
  50. package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.js.map +1 -1
  51. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +6 -6
  52. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
  53. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +15 -14
  54. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
  55. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts +4 -4
  56. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts.map +1 -1
  57. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +9 -8
  58. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
  59. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts +2 -2
  60. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -1
  61. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +9 -7
  62. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -1
  63. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts +2 -2
  64. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
  65. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +7 -5
  66. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
  67. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +2 -2
  68. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts.map +1 -1
  69. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +4 -3
  70. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
  71. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +2 -2
  72. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts.map +1 -1
  73. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +5 -4
  74. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
  75. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts +2 -2
  76. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts.map +1 -1
  77. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +10 -4
  78. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
  79. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +46 -12
  80. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
  81. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +93 -43
  82. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
  83. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +2 -2
  84. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
  85. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +5 -4
  86. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
  87. package/dist/services/obsidian/jsonlogic-evaluator.d.ts +33 -8
  88. package/dist/services/obsidian/jsonlogic-evaluator.d.ts.map +1 -1
  89. package/dist/services/obsidian/jsonlogic-evaluator.js +171 -32
  90. package/dist/services/obsidian/jsonlogic-evaluator.js.map +1 -1
  91. package/dist/services/obsidian/jsonlogic-worker.d.ts +6 -5
  92. package/dist/services/obsidian/jsonlogic-worker.d.ts.map +1 -1
  93. package/dist/services/obsidian/jsonlogic-worker.js +7 -5
  94. package/dist/services/obsidian/jsonlogic-worker.js.map +1 -1
  95. package/dist/services/obsidian/obsidian-service.d.ts +38 -22
  96. package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
  97. package/dist/services/obsidian/obsidian-service.js +293 -99
  98. package/dist/services/obsidian/obsidian-service.js.map +1 -1
  99. package/dist/services/obsidian/patch-instruction.d.ts +4 -1
  100. package/dist/services/obsidian/patch-instruction.d.ts.map +1 -1
  101. package/dist/services/obsidian/patch-instruction.js +5 -2
  102. package/dist/services/obsidian/patch-instruction.js.map +1 -1
  103. package/dist/services/obsidian/path-policy.d.ts +67 -23
  104. package/dist/services/obsidian/path-policy.d.ts.map +1 -1
  105. package/dist/services/obsidian/path-policy.js +183 -80
  106. package/dist/services/obsidian/path-policy.js.map +1 -1
  107. package/dist/services/obsidian/section-extractor.d.ts +6 -1
  108. package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
  109. package/dist/services/obsidian/section-extractor.js +106 -11
  110. package/dist/services/obsidian/section-extractor.js.map +1 -1
  111. package/dist/services/obsidian/simple-search.d.ts +62 -0
  112. package/dist/services/obsidian/simple-search.d.ts.map +1 -0
  113. package/dist/services/obsidian/simple-search.js +264 -0
  114. package/dist/services/obsidian/simple-search.js.map +1 -0
  115. package/manifest.json +10 -2
  116. package/package.json +8 -7
  117. package/server.json +19 -7
package/AGENTS.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Agent Protocol
2
2
 
3
3
  **Server:** obsidian-mcp-server
4
- **Version:** 3.7.1
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.15`
4
+ **Version:** 3.8.0
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
- **MCP SDK:** `@modelcontextprotocol/server` ^2.3.1
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0
8
8
  **Zod:** ^4.6.7
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
@@ -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
@@ -319,9 +322,18 @@ src/
319
322
 
320
323
  ## Skills
321
324
 
322
- Skills are modular instructions in `framework-skills/` at the project root. 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. The 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 these development skills to every agent that installs it. Keep `skills/` free for skills meant for those agents.
325
+ Two skill trees can live at the project root, written for different readers:
323
326
 
324
- **Agent skill directory:** Copy skills into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
327
+ | Tree | For | Written by |
328
+ |:-----|:----|:-----------|
329
+ | `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) |
330
+ | `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 |
331
+
332
+ 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.
333
+
334
+ A published skill in root `skills/` reaches plugin installs and the `.mcpb` bundle as-is, ships in the npm package and the Docker image (`package.json` `files` and the Dockerfile already carry `skills/`), and reaches MCP clients when the server opts in with `createApp({ skills: 'skills' })`. `lint:packaging` validates the tree.
335
+
336
+ **Agent skill directory:** Copy `framework-skills/` into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
325
337
 
326
338
  Available skills:
327
339
 
@@ -335,6 +347,7 @@ Available skills:
335
347
  | `add-prompt` | Scaffold a new prompt definition |
336
348
  | `add-service` | Scaffold a new service integration |
337
349
  | `add-test` | Scaffold test file for a tool, resource, or service |
350
+ | `add-skill` | Author a skill the server publishes in root `skills/` for the agents that use it — when one earns its place, structure, validation, delivery |
338
351
  | `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues |
339
352
  | `security-pass` | Audit server for MCP-flavored security gaps: output injection, scope blast radius, input sinks, tenant isolation |
340
353
  | `tool-defs-analysis` | Read-only audit of MCP definition language across the surface — voice, leaks, defaults, recovery hints, output descriptions |
@@ -384,7 +397,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
384
397
  | `bun run format` | Auto-fix formatting (safe fixes only) |
385
398
  | `bun run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior |
386
399
  | `bun run lint:mcp` | Validate MCP definitions against the linter rules |
387
- | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity, `user_config` wiring, plugin manifest identity and env contract (run by devcheck) |
400
+ | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity, `user_config` wiring, `.mcpbignore` coverage, plugin manifest identity and env contract, the published `skills/` tree (run by devcheck) |
388
401
  | `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install |
389
402
  | `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) |
390
403
  | `bun run test:coverage` | Run tests with Istanbul coverage |
@@ -399,7 +412,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
399
412
 
400
413
  ## Bundling
401
414
 
402
- `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips two classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs (`framework-skills/`, `skills/`, `.claude/`, `.agents/`, `SKILL.md`) and platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on. MCPB is stdio-only — HTTP deployments are unaffected. Delete `manifest.json` and `.mcpbignore` if not shipping MCPB bundles; `lint:packaging` skips cleanly.
415
+ `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.
403
416
 
404
417
  **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`.
405
418
 
@@ -478,7 +491,9 @@ import { getMyService } from '@/services/my-domain/my-service.js';
478
491
  - [ ] If wrapping external API: tests include at least one sparse payload case with omitted upstream fields
479
492
  - [ ] Registered in `createApp()` arrays (directly or via barrel exports). Conditional registration (e.g. `commandToolDefinitions` behind `OBSIDIAN_ENABLE_COMMANDS`) happens in `src/index.ts`, not in the barrel
480
493
  - [ ] Tests use `createMockContext()` from `@cyanheads/mcp-ts-core/testing`
494
+ - [ ] `plugin.json` + `mcp.json` (Agent Plugins 1.0.0 — read by Codex, Cursor, GitHub Copilot, VS Code, Kiro) populated — `plugin.json` `name` = the unscoped repo name; `version`, `description`, `author`, `homepage`, `repository`, `license`, `keywords` from `package.json`; no other top-level field, `extensions` included. `mcp.json` holds one `"type": "stdio"` entry keyed by the unscoped name, `npx -y obsidian-mcp-server`, and the same fixed `env` as `.codex-plugin/mcp.json`. Neither file carries a user-supplied variable (no `""`, no `${…}` besides `${PLUGIN_ROOT}`/`${PLUGIN_DATA}`): keys reach Codex through `.codex-plugin/mcp.json` `env_vars`, which works only while `.codex-plugin/plugin.json` keeps `"mcpServers": "./.codex-plugin/mcp.json"`
481
495
  - [ ] `.codex-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; `interface.displayName` = the unscoped repo name (never the npm scope — `lint:packaging` enforces this); `interface.shortDescription` from `package.json` description
482
496
  - [ ] `.codex-plugin/mcp.json` updated — server name key is the unscoped repo name; every user-supplied variable (API key, instance URL) is listed in `env_vars` so Codex forwards it from the user's environment. Never write `"KEY": ""` into `env` — an empty value replaces the user's exported key and is read as unset
483
497
  - [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `author`, `repository`, `license`, `keywords` from `package.json`; inline `mcpServers` entry keyed by the unscoped repo name. Every user-supplied variable is declared under `userConfig` (`type`, `title`, `description`; `sensitive: true` for keys and tokens; `required: true` or `default: ""`) and referenced from `env` as `"KEY": "${user_config.<option>}"` — mirror the `user_config` block in `manifest.json`. Never write `"KEY": ""` into `env`
498
+ - [ ] Any new root dev path has a root-anchored line in `.mcpbignore`; `bun run bundle` and `lint:packaging` pass
484
499
  - [ ] `bun run devcheck` passes
package/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Agent Protocol
2
2
 
3
3
  **Server:** obsidian-mcp-server
4
- **Version:** 3.7.1
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.15`
4
+ **Version:** 3.8.0
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
- **MCP SDK:** `@modelcontextprotocol/server` ^2.3.1
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0
8
8
  **Zod:** ^4.6.7
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
@@ -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
@@ -319,9 +322,18 @@ src/
319
322
 
320
323
  ## Skills
321
324
 
322
- Skills are modular instructions in `framework-skills/` at the project root. 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. The 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 these development skills to every agent that installs it. Keep `skills/` free for skills meant for those agents.
325
+ Two skill trees can live at the project root, written for different readers:
323
326
 
324
- **Agent skill directory:** Copy skills into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
327
+ | Tree | For | Written by |
328
+ |:-----|:----|:-----------|
329
+ | `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) |
330
+ | `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 |
331
+
332
+ 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.
333
+
334
+ A published skill in root `skills/` reaches plugin installs and the `.mcpb` bundle as-is, ships in the npm package and the Docker image (`package.json` `files` and the Dockerfile already carry `skills/`), and reaches MCP clients when the server opts in with `createApp({ skills: 'skills' })`. `lint:packaging` validates the tree.
335
+
336
+ **Agent skill directory:** Copy `framework-skills/` into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory.
325
337
 
326
338
  Available skills:
327
339
 
@@ -335,6 +347,7 @@ Available skills:
335
347
  | `add-prompt` | Scaffold a new prompt definition |
336
348
  | `add-service` | Scaffold a new service integration |
337
349
  | `add-test` | Scaffold test file for a tool, resource, or service |
350
+ | `add-skill` | Author a skill the server publishes in root `skills/` for the agents that use it — when one earns its place, structure, validation, delivery |
338
351
  | `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues |
339
352
  | `security-pass` | Audit server for MCP-flavored security gaps: output injection, scope blast radius, input sinks, tenant isolation |
340
353
  | `tool-defs-analysis` | Read-only audit of MCP definition language across the surface — voice, leaks, defaults, recovery hints, output descriptions |
@@ -384,7 +397,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
384
397
  | `bun run format` | Auto-fix formatting (safe fixes only) |
385
398
  | `bun run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior |
386
399
  | `bun run lint:mcp` | Validate MCP definitions against the linter rules |
387
- | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity, `user_config` wiring, plugin manifest identity and env contract (run by devcheck) |
400
+ | `bun run lint:packaging` | Packaging surface checks — `server.json`/`manifest.json` env-var parity, `user_config` wiring, `.mcpbignore` coverage, plugin manifest identity and env contract, the published `skills/` tree (run by devcheck) |
388
401
  | `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install |
389
402
  | `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) |
390
403
  | `bun run test:coverage` | Run tests with Istanbul coverage |
@@ -399,7 +412,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
399
412
 
400
413
  ## Bundling
401
414
 
402
- `bun run bundle` produces a `.mcpb` extension bundle for one-click install in Claude Desktop. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips two classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs (`framework-skills/`, `skills/`, `.claude/`, `.agents/`, `SKILL.md`) and platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on. MCPB is stdio-only — HTTP deployments are unaffected. Delete `manifest.json` and `.mcpbignore` if not shipping MCPB bundles; `lint:packaging` skips cleanly.
415
+ `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.
403
416
 
404
417
  **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`.
405
418
 
@@ -478,7 +491,9 @@ import { getMyService } from '@/services/my-domain/my-service.js';
478
491
  - [ ] If wrapping external API: tests include at least one sparse payload case with omitted upstream fields
479
492
  - [ ] Registered in `createApp()` arrays (directly or via barrel exports). Conditional registration (e.g. `commandToolDefinitions` behind `OBSIDIAN_ENABLE_COMMANDS`) happens in `src/index.ts`, not in the barrel
480
493
  - [ ] Tests use `createMockContext()` from `@cyanheads/mcp-ts-core/testing`
494
+ - [ ] `plugin.json` + `mcp.json` (Agent Plugins 1.0.0 — read by Codex, Cursor, GitHub Copilot, VS Code, Kiro) populated — `plugin.json` `name` = the unscoped repo name; `version`, `description`, `author`, `homepage`, `repository`, `license`, `keywords` from `package.json`; no other top-level field, `extensions` included. `mcp.json` holds one `"type": "stdio"` entry keyed by the unscoped name, `npx -y obsidian-mcp-server`, and the same fixed `env` as `.codex-plugin/mcp.json`. Neither file carries a user-supplied variable (no `""`, no `${…}` besides `${PLUGIN_ROOT}`/`${PLUGIN_DATA}`): keys reach Codex through `.codex-plugin/mcp.json` `env_vars`, which works only while `.codex-plugin/plugin.json` keeps `"mcpServers": "./.codex-plugin/mcp.json"`
481
495
  - [ ] `.codex-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; `interface.displayName` = the unscoped repo name (never the npm scope — `lint:packaging` enforces this); `interface.shortDescription` from `package.json` description
482
496
  - [ ] `.codex-plugin/mcp.json` updated — server name key is the unscoped repo name; every user-supplied variable (API key, instance URL) is listed in `env_vars` so Codex forwards it from the user's environment. Never write `"KEY": ""` into `env` — an empty value replaces the user's exported key and is read as unset
483
497
  - [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `author`, `repository`, `license`, `keywords` from `package.json`; inline `mcpServers` entry keyed by the unscoped repo name. Every user-supplied variable is declared under `userConfig` (`type`, `title`, `description`; `sensitive: true` for keys and tokens; `required: true` or `default: ""`) and referenced from `env` as `"KEY": "${user_config.<option>}"` — mirror the `user_config` block in `manifest.json`. Never write `"KEY": ""` into `env`
498
+ - [ ] Any new root dev path has a root-anchored line in `.mcpbignore`; `bun run bundle` and `lint:packaging` pass
484
499
  - [ ] `bun run devcheck` passes
package/Dockerfile CHANGED
@@ -29,6 +29,11 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
29
29
  # Copy the rest of the source code
30
30
  COPY . .
31
31
 
32
+ # The skills a server publishes (`createApp({ skills: 'skills' })`) are read at
33
+ # runtime from the application root. Created when the project has none, so the
34
+ # production stage's copy below holds with or without one.
35
+ RUN mkdir -p skills
36
+
32
37
  # Build the application
33
38
  RUN bun run build
34
39
 
@@ -135,6 +140,7 @@ COPY --from=deps /usr/src/app/node_modules ./node_modules
135
140
 
136
141
  # Copy the compiled application code from the build stage
137
142
  COPY --from=build /usr/src/app/dist ./dist
143
+ COPY --from=build /usr/src/app/skills ./skills
138
144
 
139
145
  # The 'oven/bun' image already provides a non-root user named 'bun'.
140
146
  # We will use this existing user for enhanced security.
@@ -158,6 +164,7 @@ ENV MCP_HTTP_PORT=${PORT:-3010}
158
164
  # bind exposes the upstream OBSIDIAN_API_KEY to any caller that can reach the port.
159
165
  ENV MCP_HTTP_HOST="0.0.0.0"
160
166
  ENV MCP_TRANSPORT_TYPE="http"
167
+ ENV MCP_SESSION_MODE="stateful"
161
168
  ENV MCP_LOG_LEVEL="info"
162
169
  ENV LOGS_DIR="/var/log/obsidian-mcp-server"
163
170