obsidian-mcp-server 3.6.1 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +27 -17
- package/CLAUDE.md +27 -17
- package/README.md +22 -13
- package/changelog/3.7.x/3.7.0.md +34 -0
- package/dist/config/server-config.d.ts +1 -0
- package/dist/config/server-config.d.ts.map +1 -1
- package/dist/config/server-config.js +4 -0
- package/dist/config/server-config.js.map +1 -1
- package/dist/index.js +28 -9
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts +7 -5
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts.map +1 -1
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js +12 -8
- package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js.map +1 -1
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +30 -9
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +28 -6
- package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +230 -294
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/index.js +9 -3
- package/dist/mcp-server/tools/definitions/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +107 -10
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +237 -162
- package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +1 -0
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +61 -19
- package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +6 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +8 -10
- package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts +7 -5
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +13 -7
- package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +4 -1
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +15 -4
- package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
- package/dist/services/obsidian/obsidian-service.d.ts +7 -0
- package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
- package/dist/services/obsidian/obsidian-service.js +57 -15
- package/dist/services/obsidian/obsidian-service.js.map +1 -1
- package/dist/services/obsidian/patch-instruction.d.ts +20 -1
- package/dist/services/obsidian/patch-instruction.d.ts.map +1 -1
- package/dist/services/obsidian/patch-instruction.js +30 -0
- package/dist/services/obsidian/patch-instruction.js.map +1 -1
- package/dist/services/obsidian/path-policy.d.ts +23 -1
- package/dist/services/obsidian/path-policy.d.ts.map +1 -1
- package/dist/services/obsidian/path-policy.js +45 -3
- package/dist/services/obsidian/path-policy.js.map +1 -1
- package/dist/services/obsidian/section-extractor.d.ts +24 -0
- package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
- package/dist/services/obsidian/section-extractor.js +37 -0
- package/dist/services/obsidian/section-extractor.js.map +1 -1
- package/manifest.json +9 -1
- package/package.json +2 -2
- package/server.json +17 -3
package/AGENTS.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Agent Protocol
|
|
2
2
|
|
|
3
3
|
**Server:** obsidian-mcp-server
|
|
4
|
-
**Version:** 3.
|
|
4
|
+
**Version:** 3.7.0
|
|
5
5
|
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.12`
|
|
6
6
|
**Engines:** Bun ≥1.4.0, Node ≥24.0.0
|
|
7
7
|
**MCP SDK:** `@modelcontextprotocol/server` ^2.2.0
|
|
@@ -34,12 +34,12 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
|
|
|
34
34
|
|
|
35
35
|
- **Logic throws, framework catches.** Tool/resource handlers are pure — throw on failure, no `try/catch`. Plain `Error` is fine; the framework catches, classifies, and formats. Use error factories (`notFound()`, `validationError()`, etc.) when the error code matters.
|
|
36
36
|
- **Use `ctx.log`** for request-scoped logging. No `console` calls.
|
|
37
|
-
- **
|
|
37
|
+
- **The delete confirmation is opt-in; when on, it is gated on a consent record.** `OBSIDIAN_DELETE_ELICITATION` (default `false`) decides whether `obsidian_delete_note` confirms through `ctx.requestInput`. Off is the default for two reasons: a client that declares elicitation but cannot render the form declines every round automatically, which the server cannot tell from a real decline (#151), and the round forces a stateful HTTP session, which locks out `MCP_SESSION_MODE=stateless`. Off, `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` bound what a delete can reach — the same bound `obsidian_write_note` with `overwrite: true` has — and `destructiveHint` stays for client-side approval. The mode is the operator's choice alone: never derive it from `ctx.clientCapabilities` or from a decline. On, the handler redeems (reads and deletes) the `ctx.state` record named by `ctx.inputs.state()` before anything else, checks write scope on the resolved path before it reads the note or asks, and acts on the `ctx.inputs` answer only when that record deep-equals `{ operation, clientId, subject, target, contentHash }` for this call; otherwise it stores a fresh record (`ttl: 600`) and `return ctx.requestInput(...)` with the record id as `requestState`. An answer alone is never proof the user was asked. Never `await` user input mid-handler.
|
|
38
38
|
- **All Obsidian access goes through `getObsidianService()`.** No direct `fetch()` calls to the Local REST API in tools/resources — the service centralizes auth, TLS, timeouts, and `ctx.signal` propagation.
|
|
39
39
|
- **Secrets in env vars only.** `OBSIDIAN_API_KEY` is required; never hardcoded.
|
|
40
40
|
- **Cut noise.** Add only what earns its place: no speculative generality, no guards for states the framework already prevents (Zod-validated params, classified errors), no abstraction until a third caller proves it, no option nothing sets.
|
|
41
41
|
- **Command-palette tools are opt-in.** `obsidian_list_commands` and `obsidian_execute_command` are callable only when `OBSIDIAN_ENABLE_COMMANDS=true` — Obsidian commands are opaque and can be destructive. When the flag is unset, the entry point wraps both with `disabledTool()` so they're absent from `tools/list` (LLM can't invoke) but visible in the operator-facing manifest with a hint to enable them.
|
|
42
|
-
- **Path-policy gating goes through `PathPolicy`.** Every path-taking method on `ObsidianService` calls `policy.assertReadable` / `assertWritable` before the upstream HTTP call; `obsidian_search_notes` post-filters hits via `svc.policy.filterReadable`. Don't bypass this — `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` are the single chokepoint, and `path_forbidden` is declared on every path-taking tool's `errors[]` contract.
|
|
42
|
+
- **Path-policy gating goes through `PathPolicy`.** Every path-taking method on `ObsidianService` calls `policy.assertReadable` / `assertWritable` before the upstream HTTP call; `listFiles` gates with `assertListable` and filters entries with `filterListing` (readable entries plus folders on the way to the scope), so every listing consumer inherits the scope; `obsidian_search_notes` post-filters hits via `svc.policy.filterReadable`, and `listTags` collects per-note tags through it when `policy.restrictsReads`. Don't bypass this — `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` are the single chokepoint, and `path_forbidden` is declared on every path-taking tool's `errors[]` contract.
|
|
43
43
|
- **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them.
|
|
44
44
|
|
|
45
45
|
---
|
|
@@ -48,7 +48,7 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
|
|
|
48
48
|
|
|
49
49
|
### Tool — `obsidian_list_tags`
|
|
50
50
|
|
|
51
|
-
A small read-only tool that wraps a single
|
|
51
|
+
A small read-only tool that wraps a single service call, normalizes the response into the output schema, and renders a markdown twin in `format()`. Reduced for illustration — the live definition also carries `nameRegex` / `minCount` / `limit` inputs, the count-descending sort and cap they feed, an `errors[]` contract, and an `enrichment` block.
|
|
52
52
|
|
|
53
53
|
```ts
|
|
54
54
|
import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
@@ -64,7 +64,11 @@ export const obsidianListTags = tool('obsidian_list_tags', {
|
|
|
64
64
|
.array(
|
|
65
65
|
z.object({
|
|
66
66
|
name: z.string().describe('Tag name without the leading `#`.'),
|
|
67
|
-
count: z
|
|
67
|
+
count: z
|
|
68
|
+
.number()
|
|
69
|
+
.describe(
|
|
70
|
+
'Times the tag, or a tag nested under it, occurs across the vault. When OBSIDIAN_READ_PATHS is set: the number of readable notes carrying the tag or a tag nested under it.',
|
|
71
|
+
),
|
|
68
72
|
}).describe('A tag with its usage count.'),
|
|
69
73
|
)
|
|
70
74
|
.describe('Matching tags ordered by `count` descending.'),
|
|
@@ -92,7 +96,7 @@ export const obsidianListTags = tool('obsidian_list_tags', {
|
|
|
92
96
|
});
|
|
93
97
|
```
|
|
94
98
|
|
|
95
|
-
For a destructive tool with human-in-the-loop confirmation, see `obsidian-delete-note.tool.ts`
|
|
99
|
+
For a destructive tool with optional human-in-the-loop confirmation, see `obsidian-delete-note.tool.ts`. `src/index.ts` builds it with `buildDeleteNoteTool({ elicitation })` from `OBSIDIAN_DELETE_ELICITATION`, so the description states the active mode; the exported `obsidianDeleteNote` is the off build, kept as the specimen the definition linter and tests import. Both modes check write scope on the resolved path, then read the note before the DELETE. That read is the folder guard (`path_is_directory`), since the Local REST API's `DELETE` on a folder path removes the folder and everything in it. It is also the exact-case guard, so it goes through `note+json` (`getNoteJson`), never the raw-markdown read. On a case-insensitive filesystem the raw read and the plugin v4.x `DELETE` (`adapter.exists` + `adapter.remove`) case-fold, so a wrong-case path would read and then permanently remove the differently-cased note. `note+json` resolves through Obsidian's case-sensitive vault index on v4.x and v5.x, so a wrong-case path fails `note_missing` before any DELETE, with near matches listed in `suggestions` and never substituted. With the confirmation on, it redeems a single-use consent record from `ctx.state` (bound to the operation, the caller, the resolved path, and a SHA-256 of the note's content), suspends with `ctx.requestInput` for an embedded `elicitation/create` round when no matching record backs the answer, and branches on `ctx.inputs.view()` so a declined prompt on a matching round fails instead of re-asking. Only the confirming build declares `cancelled` in `errors[]`, since the off build never throws it. Both builds carry the `destructiveHint` annotation. Pattern: framework `api-context` § Consent gates.
|
|
96
100
|
|
|
97
101
|
### Resource — `obsidian://status`
|
|
98
102
|
|
|
@@ -132,11 +136,8 @@ This server exposes a CRUD/search surface; no recurring multi-turn pattern benef
|
|
|
132
136
|
import { z } from '@cyanheads/mcp-ts-core';
|
|
133
137
|
import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
|
|
134
138
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (typeof val === 'boolean') return val;
|
|
138
|
-
return String(val).toLowerCase().trim() === 'true' || val === '1';
|
|
139
|
-
}, z.boolean());
|
|
139
|
+
/** true/false/1/0/yes/no/on/off, case-insensitive; anything else fails startup. */
|
|
140
|
+
const envBoolean = z.union([z.boolean(), z.stringbool()]);
|
|
140
141
|
|
|
141
142
|
const ServerConfigSchema = z.object({
|
|
142
143
|
apiKey: z.string().min(1).describe('Bearer token for the Obsidian Local REST API plugin.'),
|
|
@@ -148,6 +149,9 @@ const ServerConfigSchema = z.object({
|
|
|
148
149
|
readPaths: envPathList,
|
|
149
150
|
writePaths: envPathList,
|
|
150
151
|
readOnly: envBoolean.default(false),
|
|
152
|
+
/** Opt-in delete confirmation round; when true and writes are allowed, HTTP requires a stateful session. */
|
|
153
|
+
deleteElicitation: envBoolean.default(false),
|
|
154
|
+
omnisearchUrl: z.string().url().optional(),
|
|
151
155
|
});
|
|
152
156
|
|
|
153
157
|
let _config: z.infer<typeof ServerConfigSchema> | undefined;
|
|
@@ -161,6 +165,8 @@ export function getServerConfig() {
|
|
|
161
165
|
readPaths: 'OBSIDIAN_READ_PATHS',
|
|
162
166
|
writePaths: 'OBSIDIAN_WRITE_PATHS',
|
|
163
167
|
readOnly: 'OBSIDIAN_READ_ONLY',
|
|
168
|
+
deleteElicitation: 'OBSIDIAN_DELETE_ELICITATION',
|
|
169
|
+
omnisearchUrl: 'OBSIDIAN_OMNISEARCH_URL',
|
|
164
170
|
});
|
|
165
171
|
return _config;
|
|
166
172
|
}
|
|
@@ -173,16 +179,20 @@ export function getServerConfig() {
|
|
|
173
179
|
`src/index.ts` passes two `createApp()` options that shape how the server runs:
|
|
174
180
|
|
|
175
181
|
```ts
|
|
182
|
+
const deleteGateActive = config.deleteElicitation && !config.readOnly;
|
|
183
|
+
|
|
176
184
|
await createApp({
|
|
177
185
|
// ...
|
|
178
|
-
sessionMode:
|
|
186
|
+
sessionMode: deleteGateActive
|
|
187
|
+
? { default: 'stateful', require: 'stateful' }
|
|
188
|
+
: { default: 'stateful' },
|
|
179
189
|
teardown: () => obsidian.close(),
|
|
180
190
|
});
|
|
181
191
|
```
|
|
182
192
|
|
|
183
|
-
`sessionMode` declares a requirement, not just a default: `obsidian_delete_note` confirms through `ctx.requestInput`, which a 2025-era HTTP client can only answer on a stateful session, so under stateless HTTP the tool would be unusable for those clients.
|
|
193
|
+
Unset, `MCP_SESSION_MODE` runs `stateful`. With `OBSIDIAN_DELETE_ELICITATION=true`, `sessionMode` declares a requirement, not just a default: `obsidian_delete_note` then confirms through `ctx.requestInput`, which a 2025-era HTTP client can only answer on a stateful session, so under stateless HTTP the tool would be unusable for those clients. In that mode, when the resolved HTTP mode is `stateless` (an explicit `MCP_SESSION_MODE=stateless`), startup fails with a `ConfigurationError` naming the conflict. With the confirmation off (the default), or with `OBSIDIAN_READ_ONLY=true` (which disables `obsidian_delete_note`, so no round can run), nothing needs a session and stateless HTTP starts. stdio is never refused — `MCP_SESSION_MODE` has no effect there. `tests/integration/delete-note-confirmation.test.ts` pins each case. The startup "Path policy" log line reports `deleteElicitation` as `deleteGateActive` — false under read-only, as `enableCommands` is.
|
|
184
194
|
|
|
185
|
-
The delete confirmation's consent records live in `ctx.state`, backed by the framework storage provider (`STORAGE_PROVIDER_TYPE`, default `in-memory`, process-local). That holds for stdio and for a single stateful HTTP instance, where every round of a confirmation reaches the process that asked. A multi-instance deployment, where a retry can land on another instance, needs a shared provider — `filesystem`, `supabase`, or `cloudflare-d1`, never `cloudflare-kv` (eventually consistent, so a record may be unseen or outlive its redemption). Redemption is single-use against a sequential replay but not against concurrent retries until the framework has an atomic `ctx.state.take` (cyanheads/mcp-ts-core#593).
|
|
195
|
+
The delete confirmation's consent records (on mode only) live in `ctx.state`, backed by the framework storage provider (`STORAGE_PROVIDER_TYPE`, default `in-memory`, process-local). That holds for stdio and for a single stateful HTTP instance, where every round of a confirmation reaches the process that asked. A multi-instance deployment, where a retry can land on another instance, needs a shared provider — `filesystem`, `supabase`, or `cloudflare-d1`, never `cloudflare-kv` (eventually consistent, so a record may be unseen or outlive its redemption). Redemption is single-use against a sequential replay but not against concurrent retries until the framework has an atomic `ctx.state.take` (cyanheads/mcp-ts-core#593).
|
|
186
196
|
|
|
187
197
|
`teardown` closes the undici `Agent` dispatcher `ObsidianService` holds for the Local REST API and Omnisearch, releasing its keep-alive sockets. It runs after the transport stops accepting requests, on every shutdown path.
|
|
188
198
|
|
|
@@ -195,8 +205,8 @@ Handlers receive a unified `ctx` object. Properties this server actually uses:
|
|
|
195
205
|
| Property | Description |
|
|
196
206
|
|:---------|:------------|
|
|
197
207
|
| `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. |
|
|
198
|
-
| `ctx.requestInput` / `ctx.inputs` | Multi-round-trip human-in-the-loop confirmation. Always present, both protocol eras — `obsidian_delete_note` requests a confirmation round before the DELETE. `requestInput` returns `never`, so write it in return position. |
|
|
199
|
-
| `ctx.state` | Tenant-scoped KV. Used only for `obsidian_delete_note`'s consent records (`consent/<uuid>`, 600 s TTL) — see Session posture above for provider requirements. |
|
|
208
|
+
| `ctx.requestInput` / `ctx.inputs` | Multi-round-trip human-in-the-loop confirmation. Always present, both protocol eras — with `OBSIDIAN_DELETE_ELICITATION=true`, `obsidian_delete_note` requests a confirmation round before the DELETE; off, it reads neither. `requestInput` returns `never`, so write it in return position. |
|
|
209
|
+
| `ctx.state` | Tenant-scoped KV. Used only for `obsidian_delete_note`'s consent records (`consent/<uuid>`, 600 s TTL), written only with `OBSIDIAN_DELETE_ELICITATION=true` — see Session posture above for provider requirements. |
|
|
200
210
|
| `ctx.auth` | Caller identity (`clientId`, `sub`) bound into each consent record; absent on stdio and `MCP_AUTH_MODE=none`, where the record carries empty strings. |
|
|
201
211
|
| `ctx.signal` | `AbortSignal` propagated to the Local REST API client so per-request timeouts and client cancellations cut off in-flight HTTP. |
|
|
202
212
|
| `ctx.requestId` | Unique request ID — surfaces in log lines for correlation. |
|
|
@@ -277,7 +287,7 @@ src/
|
|
|
277
287
|
mcp-server/
|
|
278
288
|
tools/definitions/
|
|
279
289
|
_shared/schemas.ts # Shared TargetSchema + SectionSchema reused across tools
|
|
280
|
-
index.ts # read/write/command tool sets + buildSearchNotesTool
|
|
290
|
+
index.ts # read/write/command tool sets + buildSearchNotesTool (Omnisearch-aware) and buildDeleteNoteTool (elicitation mode) factories
|
|
281
291
|
obsidian-*.tool.ts # 14 tool definitions (12 base + 2 opt-in command-palette pair)
|
|
282
292
|
resources/definitions/
|
|
283
293
|
index.ts # allResourceDefinitions[]
|
package/CLAUDE.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Agent Protocol
|
|
2
2
|
|
|
3
3
|
**Server:** obsidian-mcp-server
|
|
4
|
-
**Version:** 3.
|
|
4
|
+
**Version:** 3.7.0
|
|
5
5
|
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.12`
|
|
6
6
|
**Engines:** Bun ≥1.4.0, Node ≥24.0.0
|
|
7
7
|
**MCP SDK:** `@modelcontextprotocol/server` ^2.2.0
|
|
@@ -34,12 +34,12 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
|
|
|
34
34
|
|
|
35
35
|
- **Logic throws, framework catches.** Tool/resource handlers are pure — throw on failure, no `try/catch`. Plain `Error` is fine; the framework catches, classifies, and formats. Use error factories (`notFound()`, `validationError()`, etc.) when the error code matters.
|
|
36
36
|
- **Use `ctx.log`** for request-scoped logging. No `console` calls.
|
|
37
|
-
- **
|
|
37
|
+
- **The delete confirmation is opt-in; when on, it is gated on a consent record.** `OBSIDIAN_DELETE_ELICITATION` (default `false`) decides whether `obsidian_delete_note` confirms through `ctx.requestInput`. Off is the default for two reasons: a client that declares elicitation but cannot render the form declines every round automatically, which the server cannot tell from a real decline (#151), and the round forces a stateful HTTP session, which locks out `MCP_SESSION_MODE=stateless`. Off, `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` bound what a delete can reach — the same bound `obsidian_write_note` with `overwrite: true` has — and `destructiveHint` stays for client-side approval. The mode is the operator's choice alone: never derive it from `ctx.clientCapabilities` or from a decline. On, the handler redeems (reads and deletes) the `ctx.state` record named by `ctx.inputs.state()` before anything else, checks write scope on the resolved path before it reads the note or asks, and acts on the `ctx.inputs` answer only when that record deep-equals `{ operation, clientId, subject, target, contentHash }` for this call; otherwise it stores a fresh record (`ttl: 600`) and `return ctx.requestInput(...)` with the record id as `requestState`. An answer alone is never proof the user was asked. Never `await` user input mid-handler.
|
|
38
38
|
- **All Obsidian access goes through `getObsidianService()`.** No direct `fetch()` calls to the Local REST API in tools/resources — the service centralizes auth, TLS, timeouts, and `ctx.signal` propagation.
|
|
39
39
|
- **Secrets in env vars only.** `OBSIDIAN_API_KEY` is required; never hardcoded.
|
|
40
40
|
- **Cut noise.** Add only what earns its place: no speculative generality, no guards for states the framework already prevents (Zod-validated params, classified errors), no abstraction until a third caller proves it, no option nothing sets.
|
|
41
41
|
- **Command-palette tools are opt-in.** `obsidian_list_commands` and `obsidian_execute_command` are callable only when `OBSIDIAN_ENABLE_COMMANDS=true` — Obsidian commands are opaque and can be destructive. When the flag is unset, the entry point wraps both with `disabledTool()` so they're absent from `tools/list` (LLM can't invoke) but visible in the operator-facing manifest with a hint to enable them.
|
|
42
|
-
- **Path-policy gating goes through `PathPolicy`.** Every path-taking method on `ObsidianService` calls `policy.assertReadable` / `assertWritable` before the upstream HTTP call; `obsidian_search_notes` post-filters hits via `svc.policy.filterReadable`. Don't bypass this — `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` are the single chokepoint, and `path_forbidden` is declared on every path-taking tool's `errors[]` contract.
|
|
42
|
+
- **Path-policy gating goes through `PathPolicy`.** Every path-taking method on `ObsidianService` calls `policy.assertReadable` / `assertWritable` before the upstream HTTP call; `listFiles` gates with `assertListable` and filters entries with `filterListing` (readable entries plus folders on the way to the scope), so every listing consumer inherits the scope; `obsidian_search_notes` post-filters hits via `svc.policy.filterReadable`, and `listTags` collects per-note tags through it when `policy.restrictsReads`. Don't bypass this — `OBSIDIAN_READ_PATHS` / `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` are the single chokepoint, and `path_forbidden` is declared on every path-taking tool's `errors[]` contract.
|
|
43
43
|
- **Close the loop on issues.** When implementing work tracked by a GitHub issue, comment on the issue with what landed and close it. Do both — a comment without a close leaves stale issues open; a close without a comment leaves no record of what shipped. The comment is for future readers — state the concrete changes, not the conversation that produced them.
|
|
44
44
|
|
|
45
45
|
---
|
|
@@ -48,7 +48,7 @@ Tailor suggestions to what's actually missing or stale — don't recite the full
|
|
|
48
48
|
|
|
49
49
|
### Tool — `obsidian_list_tags`
|
|
50
50
|
|
|
51
|
-
A small read-only tool that wraps a single
|
|
51
|
+
A small read-only tool that wraps a single service call, normalizes the response into the output schema, and renders a markdown twin in `format()`. Reduced for illustration — the live definition also carries `nameRegex` / `minCount` / `limit` inputs, the count-descending sort and cap they feed, an `errors[]` contract, and an `enrichment` block.
|
|
52
52
|
|
|
53
53
|
```ts
|
|
54
54
|
import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
@@ -64,7 +64,11 @@ export const obsidianListTags = tool('obsidian_list_tags', {
|
|
|
64
64
|
.array(
|
|
65
65
|
z.object({
|
|
66
66
|
name: z.string().describe('Tag name without the leading `#`.'),
|
|
67
|
-
count: z
|
|
67
|
+
count: z
|
|
68
|
+
.number()
|
|
69
|
+
.describe(
|
|
70
|
+
'Times the tag, or a tag nested under it, occurs across the vault. When OBSIDIAN_READ_PATHS is set: the number of readable notes carrying the tag or a tag nested under it.',
|
|
71
|
+
),
|
|
68
72
|
}).describe('A tag with its usage count.'),
|
|
69
73
|
)
|
|
70
74
|
.describe('Matching tags ordered by `count` descending.'),
|
|
@@ -92,7 +96,7 @@ export const obsidianListTags = tool('obsidian_list_tags', {
|
|
|
92
96
|
});
|
|
93
97
|
```
|
|
94
98
|
|
|
95
|
-
For a destructive tool with human-in-the-loop confirmation, see `obsidian-delete-note.tool.ts`
|
|
99
|
+
For a destructive tool with optional human-in-the-loop confirmation, see `obsidian-delete-note.tool.ts`. `src/index.ts` builds it with `buildDeleteNoteTool({ elicitation })` from `OBSIDIAN_DELETE_ELICITATION`, so the description states the active mode; the exported `obsidianDeleteNote` is the off build, kept as the specimen the definition linter and tests import. Both modes check write scope on the resolved path, then read the note before the DELETE. That read is the folder guard (`path_is_directory`), since the Local REST API's `DELETE` on a folder path removes the folder and everything in it. It is also the exact-case guard, so it goes through `note+json` (`getNoteJson`), never the raw-markdown read. On a case-insensitive filesystem the raw read and the plugin v4.x `DELETE` (`adapter.exists` + `adapter.remove`) case-fold, so a wrong-case path would read and then permanently remove the differently-cased note. `note+json` resolves through Obsidian's case-sensitive vault index on v4.x and v5.x, so a wrong-case path fails `note_missing` before any DELETE, with near matches listed in `suggestions` and never substituted. With the confirmation on, it redeems a single-use consent record from `ctx.state` (bound to the operation, the caller, the resolved path, and a SHA-256 of the note's content), suspends with `ctx.requestInput` for an embedded `elicitation/create` round when no matching record backs the answer, and branches on `ctx.inputs.view()` so a declined prompt on a matching round fails instead of re-asking. Only the confirming build declares `cancelled` in `errors[]`, since the off build never throws it. Both builds carry the `destructiveHint` annotation. Pattern: framework `api-context` § Consent gates.
|
|
96
100
|
|
|
97
101
|
### Resource — `obsidian://status`
|
|
98
102
|
|
|
@@ -132,11 +136,8 @@ This server exposes a CRUD/search surface; no recurring multi-turn pattern benef
|
|
|
132
136
|
import { z } from '@cyanheads/mcp-ts-core';
|
|
133
137
|
import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
|
|
134
138
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (typeof val === 'boolean') return val;
|
|
138
|
-
return String(val).toLowerCase().trim() === 'true' || val === '1';
|
|
139
|
-
}, z.boolean());
|
|
139
|
+
/** true/false/1/0/yes/no/on/off, case-insensitive; anything else fails startup. */
|
|
140
|
+
const envBoolean = z.union([z.boolean(), z.stringbool()]);
|
|
140
141
|
|
|
141
142
|
const ServerConfigSchema = z.object({
|
|
142
143
|
apiKey: z.string().min(1).describe('Bearer token for the Obsidian Local REST API plugin.'),
|
|
@@ -148,6 +149,9 @@ const ServerConfigSchema = z.object({
|
|
|
148
149
|
readPaths: envPathList,
|
|
149
150
|
writePaths: envPathList,
|
|
150
151
|
readOnly: envBoolean.default(false),
|
|
152
|
+
/** Opt-in delete confirmation round; when true and writes are allowed, HTTP requires a stateful session. */
|
|
153
|
+
deleteElicitation: envBoolean.default(false),
|
|
154
|
+
omnisearchUrl: z.string().url().optional(),
|
|
151
155
|
});
|
|
152
156
|
|
|
153
157
|
let _config: z.infer<typeof ServerConfigSchema> | undefined;
|
|
@@ -161,6 +165,8 @@ export function getServerConfig() {
|
|
|
161
165
|
readPaths: 'OBSIDIAN_READ_PATHS',
|
|
162
166
|
writePaths: 'OBSIDIAN_WRITE_PATHS',
|
|
163
167
|
readOnly: 'OBSIDIAN_READ_ONLY',
|
|
168
|
+
deleteElicitation: 'OBSIDIAN_DELETE_ELICITATION',
|
|
169
|
+
omnisearchUrl: 'OBSIDIAN_OMNISEARCH_URL',
|
|
164
170
|
});
|
|
165
171
|
return _config;
|
|
166
172
|
}
|
|
@@ -173,16 +179,20 @@ export function getServerConfig() {
|
|
|
173
179
|
`src/index.ts` passes two `createApp()` options that shape how the server runs:
|
|
174
180
|
|
|
175
181
|
```ts
|
|
182
|
+
const deleteGateActive = config.deleteElicitation && !config.readOnly;
|
|
183
|
+
|
|
176
184
|
await createApp({
|
|
177
185
|
// ...
|
|
178
|
-
sessionMode:
|
|
186
|
+
sessionMode: deleteGateActive
|
|
187
|
+
? { default: 'stateful', require: 'stateful' }
|
|
188
|
+
: { default: 'stateful' },
|
|
179
189
|
teardown: () => obsidian.close(),
|
|
180
190
|
});
|
|
181
191
|
```
|
|
182
192
|
|
|
183
|
-
`sessionMode` declares a requirement, not just a default: `obsidian_delete_note` confirms through `ctx.requestInput`, which a 2025-era HTTP client can only answer on a stateful session, so under stateless HTTP the tool would be unusable for those clients.
|
|
193
|
+
Unset, `MCP_SESSION_MODE` runs `stateful`. With `OBSIDIAN_DELETE_ELICITATION=true`, `sessionMode` declares a requirement, not just a default: `obsidian_delete_note` then confirms through `ctx.requestInput`, which a 2025-era HTTP client can only answer on a stateful session, so under stateless HTTP the tool would be unusable for those clients. In that mode, when the resolved HTTP mode is `stateless` (an explicit `MCP_SESSION_MODE=stateless`), startup fails with a `ConfigurationError` naming the conflict. With the confirmation off (the default), or with `OBSIDIAN_READ_ONLY=true` (which disables `obsidian_delete_note`, so no round can run), nothing needs a session and stateless HTTP starts. stdio is never refused — `MCP_SESSION_MODE` has no effect there. `tests/integration/delete-note-confirmation.test.ts` pins each case. The startup "Path policy" log line reports `deleteElicitation` as `deleteGateActive` — false under read-only, as `enableCommands` is.
|
|
184
194
|
|
|
185
|
-
The delete confirmation's consent records live in `ctx.state`, backed by the framework storage provider (`STORAGE_PROVIDER_TYPE`, default `in-memory`, process-local). That holds for stdio and for a single stateful HTTP instance, where every round of a confirmation reaches the process that asked. A multi-instance deployment, where a retry can land on another instance, needs a shared provider — `filesystem`, `supabase`, or `cloudflare-d1`, never `cloudflare-kv` (eventually consistent, so a record may be unseen or outlive its redemption). Redemption is single-use against a sequential replay but not against concurrent retries until the framework has an atomic `ctx.state.take` (cyanheads/mcp-ts-core#593).
|
|
195
|
+
The delete confirmation's consent records (on mode only) live in `ctx.state`, backed by the framework storage provider (`STORAGE_PROVIDER_TYPE`, default `in-memory`, process-local). That holds for stdio and for a single stateful HTTP instance, where every round of a confirmation reaches the process that asked. A multi-instance deployment, where a retry can land on another instance, needs a shared provider — `filesystem`, `supabase`, or `cloudflare-d1`, never `cloudflare-kv` (eventually consistent, so a record may be unseen or outlive its redemption). Redemption is single-use against a sequential replay but not against concurrent retries until the framework has an atomic `ctx.state.take` (cyanheads/mcp-ts-core#593).
|
|
186
196
|
|
|
187
197
|
`teardown` closes the undici `Agent` dispatcher `ObsidianService` holds for the Local REST API and Omnisearch, releasing its keep-alive sockets. It runs after the transport stops accepting requests, on every shutdown path.
|
|
188
198
|
|
|
@@ -195,8 +205,8 @@ Handlers receive a unified `ctx` object. Properties this server actually uses:
|
|
|
195
205
|
| Property | Description |
|
|
196
206
|
|:---------|:------------|
|
|
197
207
|
| `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. |
|
|
198
|
-
| `ctx.requestInput` / `ctx.inputs` | Multi-round-trip human-in-the-loop confirmation. Always present, both protocol eras — `obsidian_delete_note` requests a confirmation round before the DELETE. `requestInput` returns `never`, so write it in return position. |
|
|
199
|
-
| `ctx.state` | Tenant-scoped KV. Used only for `obsidian_delete_note`'s consent records (`consent/<uuid>`, 600 s TTL) — see Session posture above for provider requirements. |
|
|
208
|
+
| `ctx.requestInput` / `ctx.inputs` | Multi-round-trip human-in-the-loop confirmation. Always present, both protocol eras — with `OBSIDIAN_DELETE_ELICITATION=true`, `obsidian_delete_note` requests a confirmation round before the DELETE; off, it reads neither. `requestInput` returns `never`, so write it in return position. |
|
|
209
|
+
| `ctx.state` | Tenant-scoped KV. Used only for `obsidian_delete_note`'s consent records (`consent/<uuid>`, 600 s TTL), written only with `OBSIDIAN_DELETE_ELICITATION=true` — see Session posture above for provider requirements. |
|
|
200
210
|
| `ctx.auth` | Caller identity (`clientId`, `sub`) bound into each consent record; absent on stdio and `MCP_AUTH_MODE=none`, where the record carries empty strings. |
|
|
201
211
|
| `ctx.signal` | `AbortSignal` propagated to the Local REST API client so per-request timeouts and client cancellations cut off in-flight HTTP. |
|
|
202
212
|
| `ctx.requestId` | Unique request ID — surfaces in log lines for correlation. |
|
|
@@ -277,7 +287,7 @@ src/
|
|
|
277
287
|
mcp-server/
|
|
278
288
|
tools/definitions/
|
|
279
289
|
_shared/schemas.ts # Shared TargetSchema + SectionSchema reused across tools
|
|
280
|
-
index.ts # read/write/command tool sets + buildSearchNotesTool
|
|
290
|
+
index.ts # read/write/command tool sets + buildSearchNotesTool (Omnisearch-aware) and buildDeleteNoteTool (elicitation mode) factories
|
|
281
291
|
obsidian-*.tool.ts # 14 tool definitions (12 base + 2 opt-in command-palette pair)
|
|
282
292
|
resources/definitions/
|
|
283
293
|
index.ts # allResourceDefinitions[]
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
<div align="center">
|
|
9
9
|
|
|
10
|
-
[](./CHANGELOG.md) [](./LICENSE) [](https://github.com/users/cyanheads/packages/container/package/obsidian-mcp-server) [](https://modelcontextprotocol.io/) [](https://www.npmjs.com/package/obsidian-mcp-server) [](https://www.typescriptlang.org/) [](https://bun.sh/)
|
|
11
11
|
|
|
12
12
|
</div>
|
|
13
13
|
|
|
@@ -39,7 +39,7 @@ Obsidian vault notes over the Local REST API plugin. Read, search, and write not
|
|
|
39
39
|
| `obsidian_replace_in_note` | Literal or regex search-replace inside one note, body-only by default |
|
|
40
40
|
| `obsidian_manage_frontmatter` | Get, set, or delete one frontmatter key |
|
|
41
41
|
| `obsidian_manage_tags` | Add, remove, or list a note's tags in frontmatter, inline, or both |
|
|
42
|
-
| `obsidian_delete_note` | Permanently delete a note after the user confirms |
|
|
42
|
+
| `obsidian_delete_note` | Permanently delete a note, optionally after the user confirms (`OBSIDIAN_DELETE_ELICITATION`) |
|
|
43
43
|
| `obsidian_open_in_ui` | Open a file in the Obsidian app, optionally in a new pane |
|
|
44
44
|
| `obsidian_list_commands` | List command-palette commands (opt-in via `OBSIDIAN_ENABLE_COMMANDS`) |
|
|
45
45
|
| `obsidian_execute_command` | Run a command-palette command by ID (opt-in via `OBSIDIAN_ENABLE_COMMANDS`) |
|
|
@@ -60,13 +60,15 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
|
|
|
60
60
|
|
|
61
61
|
- `target` is a vault `path`, the `active` file, or a `periodic` note (`daily` through `yearly`, optional `date`); `format` is `content`, `full`, `document-map`, or `section`, and `full` takes `includeLinks: true` for vault-internal outgoing links
|
|
62
62
|
- `result.format` discriminates the payload; a `section` read that matches several headings returns the first and lists every full path in `candidates`
|
|
63
|
+
- A `path` with no exact match but one case-insensitive match in its folder reads that file: `result.path` is the real name, `requestedPath` the one sent, and a `notice` names both
|
|
63
64
|
|
|
64
65
|
---
|
|
65
66
|
|
|
66
67
|
### `obsidian_list_notes` <sub>tool</sub>
|
|
67
68
|
|
|
68
69
|
- Walks from `path` (default vault root) to `depth` 1–20 (default 2), filtered by `extension` and `nameRegex` (≤256 chars); a folder that fails `nameRegex` is not walked
|
|
69
|
-
- Returns `entries[]` (`file` / `directory`), `totals`, and `appliedFilters`; the walk stops at 1,000 entries with `excluded.reason: "entry_cap"`, and a folder the depth limit
|
|
70
|
+
- Returns `entries[]` (`file` / `directory`), `totals`, and `appliedFilters`; the walk stops at 1,000 entries with `excluded.reason: "entry_cap"`, and a folder the depth limit stopped carries `truncated: true`
|
|
71
|
+
- With `OBSIDIAN_READ_PATHS` set, a listing holds only readable entries and the folders leading to the scope; those folders are walked and accepted as `path`
|
|
70
72
|
|
|
71
73
|
---
|
|
72
74
|
|
|
@@ -74,6 +76,7 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
|
|
|
74
76
|
|
|
75
77
|
- `nameRegex` (≤256 chars) and `minCount` narrow the set, then tags are ranked by count and capped at `limit` (default 200, max 10000); hierarchical parents count (`work/tasks` adds to `work`)
|
|
76
78
|
- When the cap withholds tags, the response carries `truncated`, `shown`, and `cap`
|
|
79
|
+
- With `OBSIDIAN_READ_PATHS` set, only tags from readable notes are listed, and `count` is the number of readable notes carrying the tag or a tag nested under it
|
|
77
80
|
|
|
78
81
|
---
|
|
79
82
|
|
|
@@ -131,9 +134,11 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
|
|
|
131
134
|
|
|
132
135
|
### `obsidian_delete_note` <sub>tool</sub>
|
|
133
136
|
|
|
134
|
-
- Takes a `target
|
|
135
|
-
-
|
|
136
|
-
-
|
|
137
|
+
- Takes a `target` (path, active file, or periodic note) and checks it against the write scope first; a folder path fails with `path_is_directory`. There is no API-level undo, only Obsidian's local trash
|
|
138
|
+
- The path must match exactly, letter case included, even on a case-insensitive filesystem. A different-case or extension-less spelling fails `note_missing` before anything is deleted, naming the near matches in `suggestions`
|
|
139
|
+
- By default the note is deleted on the first call, with no confirmation; `OBSIDIAN_WRITE_PATHS` and `OBSIDIAN_READ_ONLY` bound what it can reach
|
|
140
|
+
- With `OBSIDIAN_DELETE_ELICITATION=true`, the first call answers with a confirmation request naming the path and byte size, and the note is deleted only after the user accepts. Declining fails with `cancelled`, and a client without elicitation support cannot delete
|
|
141
|
+
- In that mode an answer counts only against the single-use consent record stored when the prompt was shown, bound to the caller, the path, and the note's content then. A pre-supplied or replayed answer, or one given after the note changed, gets a fresh prompt instead
|
|
137
142
|
- Consent records live in the server's storage provider (`STORAGE_PROVIDER_TYPE`, default `in-memory`, process-local). That works for stdio or a single HTTP instance; several instances behind one endpoint need a shared provider (`filesystem`, `supabase`, or `cloudflare-d1`, never `cloudflare-kv`)
|
|
138
143
|
|
|
139
144
|
---
|
|
@@ -142,6 +147,7 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
|
|
|
142
147
|
|
|
143
148
|
- `path`, `failIfMissing` (default `true`), and `newLeaf` (open in a split pane); with `failIfMissing: false` a missing file is created, which needs write access
|
|
144
149
|
- `createdIfMissing` reports which branch ran
|
|
150
|
+
- A case-mismatched `path` with one case-insensitive match opens that file instead of creating another, reporting the path sent as `requestedPath` with a `notice`
|
|
145
151
|
|
|
146
152
|
---
|
|
147
153
|
|
|
@@ -169,7 +175,8 @@ Note and tag data are also reachable through tools (`obsidian_get_note`, `obsidi
|
|
|
169
175
|
|
|
170
176
|
### `obsidian://tags` <sub>resource</sub>
|
|
171
177
|
|
|
172
|
-
- Every tag with its `count`, uncapped and
|
|
178
|
+
- Every tag with its `count`, uncapped and unsorted, hierarchical parents included
|
|
179
|
+
- With `OBSIDIAN_READ_PATHS` set, only tags from readable notes, counted the same way as `obsidian_list_tags`
|
|
173
180
|
- No ranking or filters; `obsidian_list_tags` gives the count-ranked, capped view
|
|
174
181
|
|
|
175
182
|
---
|
|
@@ -188,9 +195,9 @@ Obsidian-specific:
|
|
|
188
195
|
- Typed client for the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) plugin; section writes speak markdown-patch 2.0 to plugin v5.0 and later and 1.x to v4.x, chosen from the reported plugin version
|
|
189
196
|
- Heading targets take a full `Parent::Child` path or a bare leaf name; writes reject an ambiguous one with `ambiguous_section` and its `candidates`, unless exactly one match is a top-level heading. On plugin v5.0 and later, content added to a heading is set off by a blank line (a list item continues an adjacent list), and a heading inside it must sit below the section's level (`heading_outside_section`)
|
|
190
197
|
- Folder-scoped read/write permissions, a read-only switch, and an opt-in command-palette pair (see [Path policy](#path-policy)); server-level `instructions` on `initialize` report the active policy
|
|
191
|
-
- `obsidian_get_note` and `obsidian_open_in_ui` retry a case-mismatched path against the real filename and add `Did you mean` suggestions to a miss; writes and deletes match the exact path
|
|
198
|
+
- `obsidian_get_note` and `obsidian_open_in_ui` retry a case-mismatched path against the real filename, disclosed as `requestedPath` plus a `notice`, and add `Did you mean` suggestions to a miss; writes and deletes match the exact path
|
|
192
199
|
- Regex inputs are capped (`nameRegex` at 256 chars, `useRegex` at 1024) and rejected with `regex_unsafe` when they nest quantifiers
|
|
193
|
-
- Backlinks have no dedicated tool; `obsidian_search_notes` in `jsonlogic` mode finds them with `{"regexp": ["\\[\\[Target Note(
|
|
200
|
+
- Backlinks have no dedicated tool; `obsidian_search_notes` in `jsonlogic` mode finds them with `{"regexp": ["\\[\\[Target Note(\\\\?\\||#|\\]\\])", {"var": "content"}]}`, which also matches the table-safe `[[Target Note\|Alias]]`
|
|
194
201
|
|
|
195
202
|
Agent-friendly output:
|
|
196
203
|
|
|
@@ -274,8 +281,9 @@ MCP_TRANSPORT_TYPE=http OBSIDIAN_API_KEY=... bun run start:http
|
|
|
274
281
|
- The [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) plugin, v4.0.0 or later, enabled in your vault. Generate an API key under **Settings → Community Plugins → Local REST API** and set it as `OBSIDIAN_API_KEY`.
|
|
275
282
|
- The server defaults to `http://127.0.0.1:27123`, so enable **"Non-encrypted (HTTP) Server"** in the plugin settings, or set `OBSIDIAN_BASE_URL=https://127.0.0.1:27124` for the always-on HTTPS port (its self-signed cert is accepted while `OBSIDIAN_VERIFY_SSL=false`, the default).
|
|
276
283
|
- Periodic-note targets work natively on plugin v5.0.1 and earlier. From v5.0.2 they need the [periodic-notes API extension](https://github.com/coddingtonbear/obsidian-local-rest-api-periodic-notes); without it they fail with `periodic_unsupported`.
|
|
284
|
+
- On plugin v4.x, heading section writes to a note with CRLF (Windows) line endings land mid-line: the plugin's markdown-patch 1.x engine misplaces them, and the server can't correct it. Plugin v5.0 and later handle CRLF notes.
|
|
277
285
|
- Plugin v6.0 drops markdown-patch 1.x, which two table-row writes (`contentType: "json"`) still use: rows under a heading, and rows through a block ID on its own line below the table. On v6.0, target the table by an ID on its last row.
|
|
278
|
-
-
|
|
286
|
+
- With `OBSIDIAN_DELETE_ELICITATION=true`, an MCP client that supports elicitation and renders its form, to use `obsidian_delete_note`. Every other tool, and the default delete, works without it.
|
|
279
287
|
|
|
280
288
|
### Installation
|
|
281
289
|
|
|
@@ -316,10 +324,11 @@ MCP_TRANSPORT_TYPE=http OBSIDIAN_API_KEY=... bun run start:http
|
|
|
316
324
|
| `OBSIDIAN_READ_PATHS` | Comma-separated folder allowlist for reads. See [Path policy](#path-policy). | unset (full vault) |
|
|
317
325
|
| `OBSIDIAN_WRITE_PATHS` | Comma-separated folder allowlist for writes. See [Path policy](#path-policy). | unset (full vault) |
|
|
318
326
|
| `OBSIDIAN_READ_ONLY` | Deny every write and disable the command-palette pair. | `false` |
|
|
327
|
+
| `OBSIDIAN_DELETE_ELICITATION` | Ask the user to confirm each `obsidian_delete_note` call through an elicitation form before deleting. Needs a client that renders elicitation, and a stateful session over HTTP (not required when `OBSIDIAN_READ_ONLY=true` disables the tool). Off, the delete runs on the first call. | `false` |
|
|
319
328
|
| `OBSIDIAN_OMNISEARCH_URL` | [Omnisearch](https://github.com/scambier/obsidian-omnisearch) HTTP server URL. Unset derives from the `OBSIDIAN_BASE_URL` host on port `51361`. Probed once at startup; the `omnisearch` search mode appears only if it answers. | derived |
|
|
320
329
|
| `MCP_TRANSPORT_TYPE` | Transport: `stdio` or `http`. | `stdio` |
|
|
321
330
|
| `MCP_HTTP_PORT` | HTTP server port. | `3010` |
|
|
322
|
-
| `MCP_SESSION_MODE` | HTTP session mode: `stateful` or `auto`.
|
|
331
|
+
| `MCP_SESSION_MODE` | HTTP session mode: `stateful`, `stateless`, or `auto`. With `OBSIDIAN_DELETE_ELICITATION=true` and `OBSIDIAN_READ_ONLY` off, the server requires a stateful session, because the confirmation needs one on 2025-era clients, so `stateless` fails startup over HTTP. No effect on stdio. | `stateful` |
|
|
323
332
|
| `MCP_AUTH_MODE` | Authentication: `none`, `jwt`, or `oauth`. | `none` |
|
|
324
333
|
| `MCP_LOG_LEVEL` | Log level (RFC 5424). | `info` |
|
|
325
334
|
| `LOGS_DIR` | Directory for log files (Node.js only). | `<project-root>/logs` |
|
|
@@ -339,8 +348,8 @@ Three optional env vars limit which vault paths the tools can touch. Unset, read
|
|
|
339
348
|
|
|
340
349
|
- Matching is by prefix, recursive, and case-insensitive; trailing slashes are normalized. Write paths are also readable.
|
|
341
350
|
- `OBSIDIAN_READ_ONLY=true` removes every write tool and the command-palette pair from `tools/list`, including `obsidian_manage_frontmatter` and `obsidian_manage_tags` (so their `get` / `list` go too). `obsidian_open_in_ui` still opens existing files but won't create one.
|
|
342
|
-
- A denial fails with `path_forbidden`, echoing the active scope in `data.activeScope` and the recovery hint. Search hits outside the read scope are dropped silently, and `obsidian_list_notes`
|
|
343
|
-
- Tag listings (`obsidian_list_tags`, `obsidian://tags`)
|
|
351
|
+
- A denial fails with `path_forbidden`, echoing the active scope in `data.activeScope` and the recovery hint. Search hits outside the read scope are dropped silently, and `obsidian_list_notes` lists only readable entries plus the folders leading to the scope, so a nested scope can be browsed to from the root.
|
|
352
|
+
- Tag listings (`obsidian_list_tags`, `obsidian://tags`) under `OBSIDIAN_READ_PATHS` cover readable notes only, counting notes rather than occurrences. Write paths and read-only alone leave them vault-wide.
|
|
344
353
|
- The startup log prints the active scope.
|
|
345
354
|
|
|
346
355
|
## Running the server
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
summary: "obsidian_delete_note now deletes on the first call unless OBSIDIAN_DELETE_ELICITATION=true, and obsidian_list_tags and obsidian_list_notes no longer expose tag names or folder and note names outside OBSIDIAN_READ_PATHS."
|
|
3
|
+
breaking: false
|
|
4
|
+
security: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 3.7.0 — 2026-10-05
|
|
8
|
+
|
|
9
|
+
## Added
|
|
10
|
+
|
|
11
|
+
- **`OBSIDIAN_DELETE_ELICITATION`** (default `false`) turns the `obsidian_delete_note` confirmation round on ([#153](https://github.com/cyanheads/obsidian-mcp-server/issues/153)). Declared in `server.json`, `manifest.json`, and `.env.example`; the startup log reports the delete mode.
|
|
12
|
+
- **`requestedPath` and a `notice` on `obsidian_get_note` and `obsidian_open_in_ui`** when a path with no exact match reads or opens its single case-insensitive match instead ([#149](https://github.com/cyanheads/obsidian-mcp-server/issues/149)). Write and delete tools still match the exact path.
|
|
13
|
+
|
|
14
|
+
## Changed
|
|
15
|
+
|
|
16
|
+
- **`obsidian_delete_note` now deletes on the first call, with no confirmation round** ([#153](https://github.com/cyanheads/obsidian-mcp-server/issues/153)). Set `OBSIDIAN_DELETE_ELICITATION=true` to keep the confirmation; the stateful-session requirement over HTTP then applies only in that mode, and not under `OBSIDIAN_READ_ONLY=true` (which disables the tool), so `MCP_SESSION_MODE=stateless` starts otherwise. `OBSIDIAN_WRITE_PATHS` and `OBSIDIAN_READ_ONLY` bound what a delete reaches.
|
|
17
|
+
- **`obsidian_delete_note` checks write scope before it reads the note or builds a prompt**, so a readable note outside `OBSIDIAN_WRITE_PATHS` fails `path_forbidden` without asking ([#153](https://github.com/cyanheads/obsidian-mcp-server/issues/153)).
|
|
18
|
+
- **`obsidian_search_notes` backlink recipe** also matches `[[Target Note\|Alias]]`, the table-safe alias form.
|
|
19
|
+
|
|
20
|
+
## Fixed
|
|
21
|
+
|
|
22
|
+
- **`obsidian_get_note` `includeLinks` returns the target Obsidian resolves for `[[Note\|Alias]]`**, with no trailing backslash ([#126](https://github.com/cyanheads/obsidian-mcp-server/issues/126)). Wikilink and markdown-link scanning now follows Obsidian's own shapes (`[[Note|see [1]]]`, `Note(1).md`) and stays linear on unclosed brackets.
|
|
23
|
+
- **Heading writes on Local REST API v4.x keep their line breaks** ([#146](https://github.com/cyanheads/obsidian-mcp-server/issues/146)). An append, prepend, or replace no longer runs into the next heading or the line before. CRLF notes still land mid-line, a plugin limitation.
|
|
24
|
+
- **`obsidian_delete_note` no longer deletes a differently-cased note on a case-insensitive filesystem.** A wrong-case path fails `note_missing`, listing near matches in `suggestions`, and sends no DELETE.
|
|
25
|
+
|
|
26
|
+
## Security
|
|
27
|
+
|
|
28
|
+
- **`obsidian_list_tags` and `obsidian://tags` list only tags from readable notes when `OBSIDIAN_READ_PATHS` is set** ([#154](https://github.com/cyanheads/obsidian-mcp-server/issues/154)). Before, tag names and vault-wide counts from out-of-scope notes were returned. `count` is now the number of readable notes carrying the tag or a nested tag; write paths and read-only alone leave listings vault-wide.
|
|
29
|
+
- **`obsidian_list_notes` returns only readable entries and the folders leading to the read scope** ([#155](https://github.com/cyanheads/obsidian-mcp-server/issues/155)). Before, out-of-scope folder and root-level note names were listed. A nested scope can now be browsed to from the root, and `truncated` marks only depth-limited folders.
|
|
30
|
+
|
|
31
|
+
## Dependencies
|
|
32
|
+
|
|
33
|
+
- dev: `ignore` ^7.0.11 → ^7.0.12
|
|
34
|
+
- `marked` stays pinned at `17.0.6` (the Local REST API's markdown-patch engine depends on marked 17; marked 18 parses some blocks differently, which would split section targeting from the plugin's)
|
|
@@ -13,6 +13,7 @@ declare const ServerConfigSchema: z.ZodObject<{
|
|
|
13
13
|
readPaths: z.ZodPreprocess<z.ZodPipe<z.ZodOptional<z.ZodArray<z.ZodString>>, z.ZodTransform<string[] | undefined, string[] | undefined>>, unknown>;
|
|
14
14
|
writePaths: z.ZodPreprocess<z.ZodPipe<z.ZodOptional<z.ZodArray<z.ZodString>>, z.ZodTransform<string[] | undefined, string[] | undefined>>, unknown>;
|
|
15
15
|
readOnly: z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodCodec<z.ZodString, z.ZodBoolean>]>>;
|
|
16
|
+
deleteElicitation: z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodCodec<z.ZodString, z.ZodBoolean>]>>;
|
|
16
17
|
omnisearchUrl: z.ZodOptional<z.ZodString>;
|
|
17
18
|
}, z.core.$strip>;
|
|
18
19
|
export type ServerConfig = z.infer<typeof ServerConfigSchema>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-config.d.ts","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAgF3C,QAAA,MAAM,kBAAkB
|
|
1
|
+
{"version":3,"file":"server-config.d.ts","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAgF3C,QAAA,MAAM,kBAAkB;;;;;;;;;;;iBA4DtB,CAAC;AAEH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAI9D,wBAAgB,eAAe,IAAI,YAAY,CAc9C;AAED,iEAAiE;AACjE,wBAAgB,iBAAiB,IAAI,IAAI,CAExC"}
|
|
@@ -116,6 +116,9 @@ const ServerConfigSchema = z.object({
|
|
|
116
116
|
readOnly: envBoolean
|
|
117
117
|
.default(false)
|
|
118
118
|
.describe('Global kill switch. When true, denies every write regardless of OBSIDIAN_WRITE_PATHS, and suppresses the OBSIDIAN_ENABLE_COMMANDS pair (commands can mutate). Defaults to false.'),
|
|
119
|
+
deleteElicitation: envBoolean
|
|
120
|
+
.default(false)
|
|
121
|
+
.describe('When true, `obsidian_delete_note` asks the user to confirm each delete through an elicitation round, and HTTP startup requires a stateful session (MCP_SESSION_MODE=stateless is refused) unless OBSIDIAN_READ_ONLY=true, which disables the tool. Defaults to false: the delete runs on the first call, bounded by OBSIDIAN_WRITE_PATHS and OBSIDIAN_READ_ONLY.'),
|
|
119
122
|
omnisearchUrl: z
|
|
120
123
|
.string()
|
|
121
124
|
.url()
|
|
@@ -133,6 +136,7 @@ export function getServerConfig() {
|
|
|
133
136
|
readPaths: 'OBSIDIAN_READ_PATHS',
|
|
134
137
|
writePaths: 'OBSIDIAN_WRITE_PATHS',
|
|
135
138
|
readOnly: 'OBSIDIAN_READ_ONLY',
|
|
139
|
+
deleteElicitation: 'OBSIDIAN_DELETE_ELICITATION',
|
|
136
140
|
omnisearchUrl: 'OBSIDIAN_OMNISEARCH_URL',
|
|
137
141
|
});
|
|
138
142
|
return _config;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-config.js","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"server-config.js","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAE/D;;;;GAIG;AACH,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC;AAE1D;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,GAAG,CAAC;KAClB,UAAU,CACT,CAAC,GAAG,EAAE,EAAE;IACN,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,KAAK,IAAI;QAAE,OAAO;IAC9C,IAAI,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC;QAAE,OAAO,GAAG,CAAC;IACnC,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,OAAO,GAAG,CAAC;IACxC,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO;IAC9B,MAAM,KAAK,GAAG,GAAG;SACd,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;SACpB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IAC/B,OAAO,KAAK,CAAC;AACf,CAAC,EACD,CAAC;KACE,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;KACjB,QAAQ,EAAE;KACV,SAAS,CAAC,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE;IACxB,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO;IAChC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,GAAG,CAAC,QAAQ,CAAC;YACX,IAAI,EAAE,QAAQ;YACd,OAAO,EAAE,wDAAwD;SAClE,CAAC,CAAC;QACH,OAAO,CAAC,CAAC,KAAK,CAAC;IACjB,CAAC;IACD,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,KAAK,MAAM,GAAG,IAAI,KAAK,EAAE,CAAC;QACxB,IAAI,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;YAChD,GAAG,CAAC,QAAQ,CAAC;gBACX,IAAI,EAAE,QAAQ;gBACd,OAAO,EAAE,8CAA8C,GAAG,GAAG;aAC9D,CAAC,CAAC;YACH,OAAO,CAAC,CAAC,KAAK,CAAC;QACjB,CAAC;QACD,MAAM,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QACpC,IAAI,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;YAC5B,GAAG,CAAC,QAAQ,CAAC;gBACX,IAAI,EAAE,QAAQ;gBACd,OAAO,EAAE,yCAAyC,GAAG,GAAG;aACzD,CAAC,CAAC;YACH,OAAO,CAAC,CAAC,KAAK,CAAC;QACjB,CAAC;QACD,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QAC7E,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QACtC,IAAI,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC;YAAE,SAAS;QACnC,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;QACrB,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IACvB,CAAC;IACD,OAAO,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC;AAC1C,CAAC,CAAC,CACL;KACA,QAAQ,CACP,+FAA+F,CAChG,CAAC;AAEJ,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IAClC,MAAM,EAAE,CAAC;SACN,MAAM,EAAE;SACR,GAAG,CAAC,CAAC,CAAC;SACN,QAAQ,CACP,sGAAsG,CACvG;IACH,OAAO,EAAE,CAAC;SACP,MAAM,EAAE;SACR,GAAG,EAAE;SACL,OAAO,CAAC,wBAAwB,CAAC;SACjC,QAAQ,CACP,ibAAib,CAClb;QACD;;;;;WAKG;SACF,SAAS,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC9C,SAAS,EAAE,UAAU;SAClB,OAAO,CAAC,KAAK,CAAC;SACd,QAAQ,CACP,4TAA4T,CAC7T;IACH,gBAAgB,EAAE,CAAC,CAAC,MAAM;SACvB,MAAM,EAAE;SACR,GAAG,EAAE;SACL,QAAQ,EAAE;SACV,OAAO,CAAC,MAAM,CAAC;SACf,QAAQ,CAAC,sCAAsC,CAAC;IACnD,cAAc,EAAE,UAAU;SACvB,OAAO,CAAC,KAAK,CAAC;SACd,QAAQ,CACP,yKAAyK,CAC1K;IACH,SAAS,EAAE,WAAW,CAAC,QAAQ,CAC7B,yLAAyL,CAC1L;IACD,UAAU,EAAE,WAAW,CAAC,QAAQ,CAC9B,6JAA6J,CAC9J;IACD,QAAQ,EAAE,UAAU;SACjB,OAAO,CAAC,KAAK,CAAC;SACd,QAAQ,CACP,kLAAkL,CACnL;IACH,iBAAiB,EAAE,UAAU;SAC1B,OAAO,CAAC,KAAK,CAAC;SACd,QAAQ,CACP,kWAAkW,CACnW;IACH,aAAa,EAAE,CAAC;SACb,MAAM,EAAE;SACR,GAAG,EAAE;SACL,QAAQ,EAAE;SACV,QAAQ,CACP,yTAAyT,CAC1T;CACJ,CAAC,CAAC;AAIH,IAAI,OAAiC,CAAC;AAEtC,MAAM,UAAU,eAAe;IAC7B,OAAO,KAAK,cAAc,CAAC,kBAAkB,EAAE;QAC7C,MAAM,EAAE,kBAAkB;QAC1B,OAAO,EAAE,mBAAmB;QAC5B,SAAS,EAAE,qBAAqB;QAChC,gBAAgB,EAAE,6BAA6B;QAC/C,cAAc,EAAE,0BAA0B;QAC1C,SAAS,EAAE,qBAAqB;QAChC,UAAU,EAAE,sBAAsB;QAClC,QAAQ,EAAE,oBAAoB;QAC9B,iBAAiB,EAAE,6BAA6B;QAChD,aAAa,EAAE,yBAAyB;KACzC,CAAC,CAAC;IACH,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,iBAAiB;IAC/B,OAAO,GAAG,SAAS,CAAC;AACtB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { requestContextService } from '@cyanheads/mcp-ts-core/utils';
|
|
|
12
12
|
import { getServerConfig } from './config/server-config.js';
|
|
13
13
|
import { allPromptDefinitions } from './mcp-server/prompts/definitions/index.js';
|
|
14
14
|
import { allResourceDefinitions } from './mcp-server/resources/definitions/index.js';
|
|
15
|
-
import { buildSearchNotesTool, commandToolDefinitions, readToolDefinitions, writeToolDefinitions, } from './mcp-server/tools/definitions/index.js';
|
|
15
|
+
import { buildDeleteNoteTool, buildSearchNotesTool, commandToolDefinitions, readToolDefinitions, writeToolDefinitions, } from './mcp-server/tools/definitions/index.js';
|
|
16
16
|
import { getObsidianService, initObsidianService } from './services/obsidian/obsidian-service.js';
|
|
17
17
|
import { PathPolicy } from './services/obsidian/path-policy.js';
|
|
18
18
|
const config = getServerConfig();
|
|
@@ -53,12 +53,22 @@ function buildInstructions() {
|
|
|
53
53
|
}
|
|
54
54
|
return sections.join('\n\n');
|
|
55
55
|
}
|
|
56
|
+
const writeToolSet = [
|
|
57
|
+
...writeToolDefinitions,
|
|
58
|
+
buildDeleteNoteTool({ elicitation: config.deleteElicitation }),
|
|
59
|
+
];
|
|
60
|
+
/**
|
|
61
|
+
* The delete confirmation can run only when `obsidian_delete_note` is
|
|
62
|
+
* registered: `OBSIDIAN_READ_ONLY=true` disables it below, so the gate — and
|
|
63
|
+
* the session it needs — exists only with elicitation on and writes allowed.
|
|
64
|
+
*/
|
|
65
|
+
const deleteGateActive = config.deleteElicitation && !config.readOnly;
|
|
56
66
|
const writeTools = config.readOnly
|
|
57
|
-
?
|
|
67
|
+
? writeToolSet.map((def) => disabledTool(def, {
|
|
58
68
|
reason: 'Disabled by OBSIDIAN_READ_ONLY=true.',
|
|
59
69
|
hint: 'Unset OBSIDIAN_READ_ONLY (or set it to false) to enable write tools.',
|
|
60
70
|
}))
|
|
61
|
-
:
|
|
71
|
+
: writeToolSet;
|
|
62
72
|
const commandTools = config.enableCommands && !config.readOnly
|
|
63
73
|
? commandToolDefinitions
|
|
64
74
|
: commandToolDefinitions.map((def) => disabledTool(def, {
|
|
@@ -78,28 +88,37 @@ const { services } = await createApp({
|
|
|
78
88
|
prompts: allPromptDefinitions,
|
|
79
89
|
instructions: buildInstructions(),
|
|
80
90
|
/**
|
|
91
|
+
* With `OBSIDIAN_DELETE_ELICITATION=true` (and writes allowed),
|
|
81
92
|
* `obsidian_delete_note` confirms through `ctx.requestInput`, which a
|
|
82
93
|
* 2025-era HTTP client can only answer on a stateful session — under
|
|
83
94
|
* stateless HTTP the tool would be unusable for them. Required, not just
|
|
84
|
-
* defaulted: over HTTP, a resolved `stateless` mode (an
|
|
85
|
-
* `MCP_SESSION_MODE=stateless`) fails startup with a
|
|
86
|
-
*
|
|
95
|
+
* defaulted, in that mode: over HTTP, a resolved `stateless` mode (an
|
|
96
|
+
* explicit `MCP_SESSION_MODE=stateless`) fails startup with a
|
|
97
|
+
* `ConfigurationError`. With the confirmation off, or the tool disabled by
|
|
98
|
+
* `OBSIDIAN_READ_ONLY=true`, nothing needs a session and `stateful` is only
|
|
99
|
+
* the default. stdio is never refused; `MCP_SESSION_MODE` has no effect there.
|
|
87
100
|
*/
|
|
88
|
-
sessionMode:
|
|
101
|
+
sessionMode: deleteGateActive
|
|
102
|
+
? { default: 'stateful', require: 'stateful' }
|
|
103
|
+
: { default: 'stateful' },
|
|
89
104
|
teardown: () => obsidian.close(),
|
|
90
105
|
});
|
|
91
106
|
/**
|
|
92
107
|
* Startup banner — emitted after createApp() returns so the framework's
|
|
93
108
|
* `logger.initialize()` has run; calls inside `setup()` happen pre-init and
|
|
94
109
|
* are dropped. Operators check this against their config to verify the active
|
|
95
|
-
* path policy
|
|
96
|
-
*
|
|
110
|
+
* path policy and whether `obsidian_delete_note` asks before deleting
|
|
111
|
+
* (`deleteElicitation`, false under read-only like `enableCommands`, since
|
|
112
|
+
* the tool is disabled there). The active scope on `path_forbidden` errors echoes
|
|
113
|
+
* the same data so the LLM (or operator) can self-correct without scrolling
|
|
114
|
+
* the log.
|
|
97
115
|
*/
|
|
98
116
|
const bannerCtx = requestContextService.createRequestContext({
|
|
99
117
|
operation: 'startup',
|
|
100
118
|
additionalContext: {
|
|
101
119
|
...policy.describe(),
|
|
102
120
|
enableCommands: config.enableCommands && !config.readOnly,
|
|
121
|
+
deleteElicitation: deleteGateActive,
|
|
103
122
|
omnisearchUrl: obsidian.omnisearchUrl,
|
|
104
123
|
omnisearchReachable,
|
|
105
124
|
},
|