obsidian-native-mcp 1.2.0 → 1.3.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.
@@ -20,8 +20,8 @@ jobs:
20
20
  runs-on: ubuntu-latest
21
21
  if: github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip ci]')
22
22
  steps:
23
- - uses: actions/checkout@v4
24
- - uses: actions/setup-node@v4
23
+ - uses: actions/checkout@v6
24
+ - uses: actions/setup-node@v6
25
25
  with:
26
26
  node-version-file: .nvmrc
27
27
  cache: npm
@@ -33,8 +33,8 @@ jobs:
33
33
  runs-on: ubuntu-latest
34
34
  if: github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip ci]')
35
35
  steps:
36
- - uses: actions/checkout@v4
37
- - uses: actions/setup-node@v4
36
+ - uses: actions/checkout@v6
37
+ - uses: actions/setup-node@v6
38
38
  with:
39
39
  node-version-file: .nvmrc
40
40
  cache: npm
@@ -47,8 +47,8 @@ jobs:
47
47
  runs-on: ubuntu-latest
48
48
  if: github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip ci]')
49
49
  steps:
50
- - uses: actions/checkout@v4
51
- - uses: actions/setup-node@v4
50
+ - uses: actions/checkout@v6
51
+ - uses: actions/setup-node@v6
52
52
  with:
53
53
  node-version-file: .nvmrc
54
54
  cache: npm
@@ -61,8 +61,8 @@ jobs:
61
61
  runs-on: ubuntu-latest
62
62
  if: github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip ci]')
63
63
  steps:
64
- - uses: actions/checkout@v4
65
- - uses: actions/setup-node@v4
64
+ - uses: actions/checkout@v6
65
+ - uses: actions/setup-node@v6
66
66
  with:
67
67
  node-version-file: .nvmrc
68
68
  cache: npm
@@ -75,8 +75,8 @@ jobs:
75
75
  runs-on: ubuntu-latest
76
76
  if: github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip ci]')
77
77
  steps:
78
- - uses: actions/checkout@v4
79
- - uses: actions/setup-node@v4
78
+ - uses: actions/checkout@v6
79
+ - uses: actions/setup-node@v6
80
80
  with:
81
81
  node-version-file: .nvmrc
82
82
  cache: npm
@@ -97,7 +97,7 @@ jobs:
97
97
  - run: npm ci
98
98
  - run: npm run build
99
99
  - run: npm run build:plugin
100
- - uses: actions/upload-artifact@v4
100
+ - uses: actions/upload-artifact@v7
101
101
  with:
102
102
  name: dist
103
103
  path: dist/
@@ -89,13 +89,13 @@ jobs:
89
89
  github.event.workflow_run.head_branch == 'main'
90
90
  runs-on: ubuntu-latest
91
91
  steps:
92
- - uses: actions/checkout@v4
92
+ - uses: actions/checkout@v6
93
93
  with:
94
94
  fetch-depth: 0
95
95
  persist-credentials: false
96
96
  ref: ${{ github.event.workflow_run.head_sha || github.sha }}
97
97
 
98
- - uses: actions/setup-node@v4
98
+ - uses: actions/setup-node@v6
99
99
  with:
100
100
  node-version-file: .nvmrc
101
101
  cache: npm
@@ -125,13 +125,13 @@ jobs:
125
125
  if: ${{ inputs.reason != '' }}
126
126
  run: echo "Manual release reason - ${{ inputs.reason }}" >> "$GITHUB_STEP_SUMMARY"
127
127
 
128
- - uses: actions/checkout@v4
128
+ - uses: actions/checkout@v6
129
129
  with:
130
130
  fetch-depth: 0
131
131
  persist-credentials: false
132
132
  ref: ${{ github.event.workflow_run.head_sha || github.sha }}
133
133
 
134
- - uses: actions/setup-node@v4
134
+ - uses: actions/setup-node@v6
135
135
  with:
136
136
  node-version-file: .nvmrc
137
137
  cache: npm
package/CHANGELOG.md CHANGED
@@ -1,6 +1,6 @@
1
- # [1.2.0](https://github.com/usrivastava92/obsidian-native-mcp/compare/v1.1.0...v1.2.0) (2026-05-21)
1
+ # [1.3.0](https://github.com/usrivastava92/obsidian-native-mcp/compare/v1.2.0...v1.3.0) (2026-05-23)
2
2
 
3
3
 
4
4
  ### Features
5
5
 
6
- * v1.0 complete rewrite — LLM-optimized MCP server ([3215b40](https://github.com/usrivastava92/obsidian-native-mcp/commit/3215b40a12a241dcad001532efa6021d33f12465))
6
+ * v1.0 complete rewrite — LLM-optimized MCP server ([60b0bd0](https://github.com/usrivastava92/obsidian-native-mcp/commit/60b0bd0aa65a015768c89925a7e9109a0c91b305))
package/DESIGN_V1.md ADDED
@@ -0,0 +1,583 @@
1
+ # obsidian-native-mcp — v1.0 Design Document
2
+
3
+ > **Status:** Canonical plan. Re-read this file before resuming work.
4
+ > **Audience:** Future maintainers + any AI coding agent picking up the build.
5
+ > **Decision authority:** Anything contradicting this doc is wrong unless this doc is updated first.
6
+
7
+ ---
8
+
9
+ ## 0. North Star
10
+
11
+ Build a **correct, surgically-editable, LLM-context-efficient** MCP server for Obsidian
12
+ vaults.
13
+
14
+ Three non-negotiables, in priority order:
15
+
16
+ 1. **Correctness.** No tool ever silently corrupts data. Structural operations are
17
+ AST-aware. Frontmatter goes through a real YAML library. Concurrent edits are
18
+ detected via hash preconditions.
19
+ 2. **LLM efficiency.** Writes are surgical by default. Whole-file rewrites exist
20
+ but are an explicit, documented-heavy escape hatch. Reads return hashes so the
21
+ next edit can be tiny.
22
+ 3. **Honest scope.** Reads are first-class at any size (AGENTS.md, rules,
23
+ guidelines must "just work"). Writes are surgical-first.
24
+
25
+ ---
26
+
27
+ ## 1. Architecture
28
+
29
+ ```
30
+ src/
31
+ cli/
32
+ index.ts # stdio entry; reads config; --read-only flag
33
+
34
+ plugin/
35
+ main.ts # Obsidian Plugin entry
36
+ settings.ts # Vault picker + token + read-only toggle + per-tool toggles
37
+
38
+ mcp/
39
+ framing.ts # Content-Length framing (encode/decode)
40
+ protocol.ts # JSON-RPC types
41
+ server.ts # Factory: createServer(deps) → {tools, handleRequest}
42
+ transport.ts # Transport interface
43
+ stdio.ts # Stdio impl (uses framing — honestly)
44
+ http.ts # HTTP/SSE: bearer token, origin allowlist, body cap, session TTL
45
+
46
+ vault/
47
+ registry.ts # VaultRegistry (env + config file + Obsidian discovery)
48
+ permissions.ts # Read-only mode, per-tool toggles, per-vault subdir allow/deny
49
+
50
+ fs/
51
+ io.ts # readText, writeTextAtomic, ensureDir, fileExists
52
+ walk.ts # Async walker with ignores, depth control
53
+ trash.ts # Obsidian-compatible <vault>/.obsidian/trash with unique naming
54
+ paths.ts # resolveVaultPath, traversal guards
55
+
56
+ markdown/
57
+ parse.ts # mdast parse (GFM + frontmatter + wikilink plugin)
58
+ serialize.ts # mdast → markdown (round-trip safe)
59
+ fingerprint.ts # sha256 helpers: contentHash, rangeHash, sectionHash, blockHash
60
+ outline.ts # buildOutline(parsed) → heading tree
61
+ headings.ts # findAll, sectionBounds, rename, replaceBody
62
+ blocks.ts # find, replace (prefix-preserving), rename
63
+ links.ts # typed extraction (wiki/embed/header/block/md/aliased)
64
+ tags.ts # fence-aware tag extraction
65
+ frontmatter.ts # yaml-lib backed get/set/delete with nested keys
66
+
67
+ search/
68
+ query.ts # DSL parser: tag:, path:, field:, "phrase", AND/OR/NOT, since:
69
+ execute.ts # Query → paginated results with snippets + per-line hashes
70
+
71
+ cache/
72
+ file-cache.ts # LRUCache<filePath, ParsedFile> with mtime+size invalidation
73
+ index.ts # IIndex interface (Design B now; Design C swap-in later)
74
+
75
+ audit/
76
+ log.ts # JSONL audit append; size-based rotation
77
+
78
+ handlers/
79
+ register.ts # Declarative tool registry; permission gating; error envelope
80
+ args.ts # Per-tool discriminated-union arg parsing
81
+
82
+ tools/
83
+ read/*.ts # One file per read tool
84
+ write/*.ts # One file per write tool
85
+
86
+ utils/
87
+ log.ts # Structured stderr logger (already good)
88
+ types.ts # Shared TS types
89
+ ```
90
+
91
+ ---
92
+
93
+ ## 2. Dependencies (Honest List)
94
+
95
+ Runtime:
96
+
97
+ | Dep | Purpose | Approx size |
98
+ | ---------------------------------------------- | -------------------------------------------------------- | ----------- |
99
+ | `mdast-util-from-markdown` | Parse markdown → mdast | ~150KB |
100
+ | `mdast-util-to-markdown` | mdast → markdown (round-trip safe) | ~120KB |
101
+ | `mdast-util-gfm` | Tables, task lists, strikethrough, autolinks | ~80KB |
102
+ | `mdast-util-frontmatter` | Frontmatter as AST node | ~10KB |
103
+ | `micromark-extension-wiki-link` (+ mdast util) | `[[wikilinks]]` + `![[embeds]]` | ~30KB |
104
+ | `yaml` | Frontmatter read/write (block scalars, nested, comments) | ~250KB |
105
+ | `picomatch` | Glob support in `file.list`, `file.find`, allow/deny | ~60KB |
106
+
107
+ Dev only: `esbuild`, `typescript`, `eslint`, `prettier`, `husky`, `lint-staged`,
108
+ `semantic-release`, `tsx`, `obsidian` (types), `@types/node`.
109
+
110
+ README will read:
111
+
112
+ > Minimal, auditable runtime dependencies: a small set of well-known packages
113
+ > for markdown AST (`mdast`), YAML (`yaml`), and globs (`picomatch`). MCP
114
+ > framing and transport are implemented from scratch — zero MCP-layer
115
+ > dependencies.
116
+
117
+ ---
118
+
119
+ ## 3. Core Type Contract
120
+
121
+ ```ts
122
+ // src/utils/types.ts
123
+
124
+ export type Hash = string; // "sha256:<hex>"
125
+ export type Vault = string; // vault name
126
+ export type RelPath = string; // path relative to vault root, POSIX-style
127
+
128
+ export interface ParsedFile {
129
+ path: RelPath;
130
+ text: string; // canonicalised: BOM stripped, EOL = "\n"
131
+ contentHash: Hash;
132
+ mtimeMs: number;
133
+ size: number;
134
+ ast: import("mdast").Root;
135
+ lineOffsets: number[]; // index i = byte offset of line (i+1)
136
+ headings: HeadingInfo[];
137
+ blocks: BlockInfo[];
138
+ links: ExtractedLink[];
139
+ tags: string[];
140
+ frontmatter?: ParsedFrontmatter;
141
+ }
142
+
143
+ export interface HeadingInfo {
144
+ id: string; // "h-1", "h-2", ... stable for this parse
145
+ path: string; // e.g. "Tasks::Backlog::P0"
146
+ level: 1 | 2 | 3 | 4 | 5 | 6;
147
+ line: number;
148
+ endLine: number;
149
+ sectionHash: Hash; // hash of the bytes belonging to this section
150
+ childrenCount: number;
151
+ duplicateOf?: string[]; // other heading ids with same path, if any
152
+ }
153
+
154
+ export interface BlockInfo {
155
+ id: string; // "^foo"
156
+ line: number;
157
+ blockHash: Hash; // hash of the entire structural block
158
+ structuralType: "paragraph" | "list-item" | "table-row" | "callout" | "code" | "other";
159
+ }
160
+
161
+ export type ExtractedLink =
162
+ | { kind: "wiki"; target: string; alias?: string; line: number; col: number }
163
+ | { kind: "embed"; target: string; alias?: string; line: number; col: number }
164
+ | { kind: "header-ref"; target: string; heading: string; line: number; col: number }
165
+ | { kind: "block-ref"; target: string; blockId: string; line: number; col: number }
166
+ | { kind: "markdown"; text: string; url: string; line: number; col: number };
167
+
168
+ export interface ParsedFrontmatter {
169
+ raw: string; // original yaml text
170
+ data: Record<string, unknown>; // parsed
171
+ comments?: string[]; // preserved where lib allows
172
+ }
173
+
174
+ export type ToolError =
175
+ | { code: "NOT_FOUND"; message: string; details?: any }
176
+ | {
177
+ code: "DUPLICATE_TARGET";
178
+ message: string;
179
+ matches: Array<{ id: string; line: number; path?: string }>;
180
+ }
181
+ | { code: "STALE_PRECONDITION"; message: string; expected: Hash; actual: Hash; refreshHint?: any }
182
+ | { code: "PERMISSION_DENIED"; message: string; tool: string }
183
+ | { code: "INVALID_ARGS"; message: string; details?: any }
184
+ | { code: "DESTINATION_EXISTS"; message: string; path: RelPath }
185
+ | { code: "IO_ERROR"; message: string }
186
+ | { code: "PARSE_ERROR"; message: string; line?: number; col?: number }
187
+ | { code: "INTERNAL"; message: string };
188
+ ```
189
+
190
+ ---
191
+
192
+ ## 4. Hash Algorithms (canonical)
193
+
194
+ All hashes are `sha256:<hex>`. Inputs are **canonicalised** before hashing so that
195
+ hash equality is exactly the equivalence relation we want:
196
+
197
+ - Strip UTF-8 BOM.
198
+ - Normalise line endings to `\n` (CRLF, CR → LF).
199
+ - No trailing newline normalisation: we hash the canonicalised bytes verbatim.
200
+ (`writeTextFileAtomic` preserves whatever the caller wrote.)
201
+
202
+ | Hash | Input |
203
+ | --------------------------- | --------------------------------------------------------------------------------------------------------- |
204
+ | `contentHash` | Canonicalised file bytes |
205
+ | `rangeHash(file, from, to)` | Canonicalised bytes of lines `from..to` inclusive |
206
+ | `sectionHash(heading)` | Bytes of `from = heading line` to `to = endLine` inclusive |
207
+ | `blockHash(block)` | Bytes of the structural block (entire paragraph / list item / table row / etc., not just the marker line) |
208
+ | `frontmatterHash` | Canonicalised raw frontmatter text including delimiters |
209
+
210
+ Server is the **sole** producer of hashes. Clients echo what they were given.
211
+
212
+ ---
213
+
214
+ ## 5. Precondition Model
215
+
216
+ Tools fall into four precondition categories:
217
+
218
+ | Category | Tools | Precondition |
219
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
220
+ | **Hash-required** | `file.replace`, `lines.replace`, `heading.replace_body`, `block.replace`, `frontmatter.set`, `frontmatter.delete`, `regex.replace`, hard `file.delete` | Caller MUST supply the matching `expected_*_hash`. Mismatch → `STALE_PRECONDITION` with current hash. |
221
+ | **Hash-optional** | `str_replace`, `apply_patch`, `apply_edits` | `find`/diff context lines act as content preconditions. `expected_content_hash` accepted as belt-and-suspenders. |
222
+ | **Hash-not-applicable** | `file.append`, `file.create`, `file.move` (`on_conflict: error`), all read tools | No content precondition required (or N/A). |
223
+ | **Two-step** | `regex.replace` | Step 1 MUST be `dry_run: true` and returns a `proposal_token`; step 2 supplies the token AND `expected_content_hash`. |
224
+
225
+ `STALE_PRECONDITION` response always includes the **current** hash so the LLM
226
+ can refresh just the affected range and retry without an extra round trip.
227
+
228
+ ---
229
+
230
+ ## 6. Tool Reference
231
+
232
+ Naming convention: dotted noun-verb (`heading.replace_body`, `file.read_range`).
233
+ Tool names are the JSON-RPC `name` values for `tools/call`.
234
+
235
+ ### 6.1 Read tools (no write permission needed)
236
+
237
+ | Tool | Args | Returns |
238
+ | ------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
239
+ | `vault.list` | `{}` | `{ vaults: [{name, path, fileCount?}] }` |
240
+ | `vault.info` | `{ vault? }` | `{ name, path, fileCount, sizeBytes }` |
241
+ | `file.list` | `{ vault?, directory?, recursive?, glob?, limit?, offset?, sort? }` | `{ entries: [{path, type, sizeBytes, mtimeMs}], total, nextOffset? }` |
242
+ | `file.find` | `{ vault?, query, mode: "exact"\|"substring"\|"glob"\|"regex", directory?, limit?, offset? }` | `{ matches: [{path, why}], total, nextOffset? }` |
243
+ | `file.read` | `{ vault?, file, format?: "markdown"\|"json" }` | `{ file, content, contentHash, totalLines, frontmatter? }` |
244
+ | `file.read_range` | `{ vault?, file, from, to }` | `{ file, from, to, lines, rangeHash, contentHash, totalLines }` |
245
+ | `file.head` / `file.tail` | `{ vault?, file, lines?: int }` | `{ file, lines, contentHash, totalLines }` |
246
+ | `outline` | `{ vault?, file, maxDepth? }` | `{ file, contentHash, totalLines, headings: HeadingInfo[] }` |
247
+ | `heading.find` | `{ vault?, file, heading, delimiter?: "::" }` | `{ matches: HeadingInfo[], contentHash }` (returns ALL matches) |
248
+ | `block.find` | `{ vault?, file, blockId }` | `{ matches: BlockInfo[], contentHash }` |
249
+ | `frontmatter.get` | `{ vault?, file, keyPath? }` | `{ data, frontmatterHash, contentHash }` |
250
+ | `tags.list` | `{ vault?, file?, prefix? }` | `{ tags: [{tag, count, files?}] }` |
251
+ | `links.get` | `{ vault?, file, direction: "backlinks"\|"outlinks"\|"both" }` | `{ backlinks: LinkRecord[], outlinks: LinkRecord[] }` |
252
+ | `metadata.read` | `{ vault?, file }` | `{ file, contentHash, frontmatter, headings, tags, aliases, counts }` |
253
+ | `search.content` | `{ vault?, query, directory?, limit?, offset?, contextLines? }` | `{ hits: [{file, line, lineHash, snippet, before[], after[], contentHash}], total, nextOffset? }` |
254
+ | `file.diff` | `{ vault?, file, fromHash, toHash? }` | `{ unifiedDiff, fromHash, toHash }` (toHash defaults to current) |
255
+ | `prompts.list` | `{ vault? }` | `{ prompts: [{name, description, arguments?[]}] }` |
256
+ | `prompts.get` | `{ name, vault?, arguments? }` | `{ description, messages }` (Templater args substituted) |
257
+
258
+ ### 6.2 Write tools (write permission required)
259
+
260
+ **Surgical primaries (the workhorses):**
261
+
262
+ | Tool | Args | Notes |
263
+ | ------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- |
264
+ | `str_replace` | `{ vault?, file, find, replace, occurrence?: "unique"\|int\|"all", expected_content_hash?, dry_run? }` | Default `occurrence: "unique"` → errors if `find` not unique. |
265
+ | `apply_patch` | `{ vault?, file, patch, expected_content_hash?, dry_run? }` | Unified-diff format. Validates context lines verbatim per hunk. |
266
+ | `apply_edits` | `{ vault?, file, edits: [{find, replace, occurrence?}, ...], expected_content_hash?, dry_run? }` | Single read, single write, atomic in memory. |
267
+
268
+ **Structural (use returned `*_hash` from a `find`/`outline` call):**
269
+
270
+ | Tool | Args |
271
+ | ---------------------- | ---------------------------------------------------------------------------------- |
272
+ | `heading.replace_body` | `{ vault?, file, heading, expected_section_hash, content, delimiter?, dry_run? }` |
273
+ | `heading.rename` | `{ vault?, file, heading, newText, expected_section_hash, updateRefs?, dry_run? }` |
274
+ | `block.replace` | `{ vault?, file, blockId, expected_block_hash, content, dry_run? }` |
275
+ | `block.rename` | `{ vault?, file, blockId, newId, expected_block_hash, updateRefs?, dry_run? }` |
276
+ | `lines.replace` | `{ vault?, file, from, to, content, expected_range_hash, dry_run? }` |
277
+ | `lines.insert` | `{ vault?, file, line, content, dry_run? }` |
278
+ | `frontmatter.set` | `{ vault?, file, keyPath, value, expected_frontmatter_hash, dry_run? }` |
279
+ | `frontmatter.delete` | `{ vault?, file, keyPath, expected_frontmatter_hash, dry_run? }` |
280
+
281
+ **Whole-file / metadata:**
282
+
283
+ | Tool | Args | Notes |
284
+ | -------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
285
+ | `file.create` | `{ vault?, file, content }` | **Create-only**. Errors if exists. |
286
+ | `file.replace` | `{ vault?, file, content, expected_content_hash?, create_if_missing?, dry_run? }` | Description explicitly says "prefer surgical tools". `expected_content_hash` required unless `create_if_missing` is true and file does not exist. |
287
+ | `file.append` | `{ vault?, file, content, ensureTrailingNewline?, dry_run? }` |
288
+ | `file.move` | `{ vault?, from, to, on_conflict?: "error"\|"overwrite"\|"rename", update_links?, dry_run? }` | Default `on_conflict: "error"`. |
289
+ | `file.delete` | `{ vault?, file, trash?: bool, expected_content_hash?, dry_run? }` | `trash: true` → `<vault>/.obsidian/trash`. Hard delete requires `expected_content_hash`. |
290
+
291
+ **Batch + power:**
292
+
293
+ | Tool | Args | Notes |
294
+ | --------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
295
+ | `bulk.apply` | `{ vault?, ops: [{tool, args}, ...], atomic?: bool, dry_run? }` | Real atomic: snapshot all → apply all in memory → write all → rollback any on failure. |
296
+ | `regex.replace` | `{ vault?, file, pattern, replacement, flags?, count?, expected_content_hash, proposal_token? }` | **Two-step**: first call must be `dry_run: true` and returns `proposal_token`; second call supplies the token + matching `expected_content_hash`. |
297
+
298
+ ### 6.3 Removed tools
299
+
300
+ Removed without shim (pre-1.0, no users to migrate):
301
+
302
+ - `patch_file` — split into `str_replace`, `apply_patch`, `heading.replace_body`, `block.replace`, `frontmatter.set/delete`, `lines.replace`.
303
+ - `replace_section` — folded into `heading.replace_body` (no fabrication).
304
+ - `create_file`-as-overwrite — `file.create` is create-only; use `file.replace`.
305
+ - `search` (renamed to `search.content` with DSL).
306
+
307
+ ---
308
+
309
+ ## 7. Permission Model
310
+
311
+ Three orthogonal axes:
312
+
313
+ 1. **Server-level read-only mode.** CLI flag `--read-only`, plugin setting toggle.
314
+ When on, all `requiresWrite: true` tools return `PERMISSION_DENIED`.
315
+ 2. **Per-tool enable/disable.** Plugin settings expose every tool with a
316
+ checkbox. Server config (`~/.config/obsidian-native-mcp/permissions.json`)
317
+ for CLI. Disabled tools are absent from `tools/list`.
318
+ 3. **Per-vault subdir allow/deny.** Optional `allow_paths: [glob,...]`,
319
+ `deny_paths: [glob,...]` per vault. Checked at every path resolution.
320
+
321
+ ---
322
+
323
+ ## 8. Concurrency / Atomicity
324
+
325
+ - Single-file writes: always `writeTextFileAtomic` (temp file + rename).
326
+ - `apply_edits`: read once, all edits applied in memory, single atomic write.
327
+ - `bulk.apply`:
328
+ - Snapshot every affected file's text + hash in memory.
329
+ - Apply every op against in-memory state; abort on first failure.
330
+ - On full success: write each file atomically in order.
331
+ - On failure: nothing has been written yet — return errors per op.
332
+ - Documented as "process-atomic" — not crash-atomic across files.
333
+ - Hash preconditions are the cross-process concurrency guard. There is no
334
+ vault-wide lock.
335
+
336
+ ---
337
+
338
+ ## 9. Audit Log
339
+
340
+ `<vault>/.obsidian/plugins/obsidian-native-mcp/audit.log` — JSONL. One line per
341
+ mutating tool call:
342
+
343
+ ```json
344
+ {
345
+ "ts": "2026-05-21T13:45:12.000Z",
346
+ "tool": "str_replace",
347
+ "vault": "personal",
348
+ "file": "Daily/2026-05-21.md",
349
+ "args_hash": "sha256:...",
350
+ "before_hash": "sha256:...",
351
+ "after_hash": "sha256:...",
352
+ "dry_run": false,
353
+ "client_id": "session-7f3c..."
354
+ }
355
+ ```
356
+
357
+ - No raw args logged (avoids leaking sensitive note bodies).
358
+ - Rotation: when file size > 10 MB, rename to `audit.log.1` (keep 5 rotations).
359
+ - Read-only mode and dry-runs still log (with `dry_run: true`) for visibility.
360
+
361
+ ---
362
+
363
+ ## 10. Transports
364
+
365
+ ### 10.1 Stdio
366
+
367
+ - Real Content-Length framing both directions.
368
+ - Stream parser tolerates partial reads and concatenated messages.
369
+ - stderr for logs; stdout reserved for JSON-RPC.
370
+
371
+ ### 10.2 HTTP/SSE
372
+
373
+ - `GET /sse?token=<bearer>` → SSE stream; `event: endpoint` carries
374
+ `/message?session_id=<sid>&token=<bearer>`.
375
+ - `POST /message` requires:
376
+ - Matching bearer token.
377
+ - `Origin` header in allowlist (default: empty/none/`null`/loopback).
378
+ - Body ≤ 5 MB.
379
+ - Session limits: max 16 concurrent SSE sessions; idle timeout 5 min; heartbeat
380
+ every 30 s.
381
+ - `GET /healthz` → `{ ok: true, version, vaults, readOnly }`.
382
+ - Token regeneration via plugin settings ("Rotate token" button).
383
+ - CORS: only configured origins; never `*`.
384
+
385
+ ---
386
+
387
+ ## 11. Caching (Design B)
388
+
389
+ ```ts
390
+ class LRUFileIndex implements IIndex {
391
+ private cache = new Map<RelPath, ParsedFile>(); // simple LRU
392
+ private maxEntries = 256;
393
+
394
+ async getFile(absPath: string): Promise<ParsedFile> {
395
+ const stat = await fs.stat(absPath);
396
+ const cached = this.cache.get(absPath);
397
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
398
+ this.touch(absPath);
399
+ return cached;
400
+ }
401
+ const text = await fs.readFile(absPath, "utf-8");
402
+ const parsed = parseMarkdown(absPath, text, stat);
403
+ this.put(absPath, parsed);
404
+ return parsed;
405
+ }
406
+
407
+ invalidate(absPath: string): void {
408
+ this.cache.delete(absPath);
409
+ }
410
+ refreshAfterWrite(absPath: string, text: string, stat: Stats): void {
411
+ this.put(absPath, parseMarkdown(absPath, text, stat));
412
+ }
413
+ }
414
+ ```
415
+
416
+ After any successful write, refresh the cache entry inline using the post-write
417
+ text already in memory (avoid a re-parse from disk).
418
+
419
+ The `IIndex` interface keeps the seam open to swap to a watched, vault-wide
420
+ index later (Design C) without changing tool code.
421
+
422
+ ---
423
+
424
+ ## 12. Test Strategy
425
+
426
+ ### 12.1 Fixtures (`tests/fixtures/vaults/`)
427
+
428
+ `tiny`, `agents` (~250 LoC), `daily-note`, `large-kb` (~5,000 LoC),
429
+ `code-heavy`, `frontmatter-stress`, `links-zoo`, `dup-headings`, `setext`,
430
+ `bom-crlf`, `unicode`, `huge-line`, `pathological-fm`, `obsidian-features`.
431
+
432
+ Each fixture has a sibling `EXPECTED.md` documenting what's deliberately weird
433
+ about it.
434
+
435
+ ### 12.2 Test layers
436
+
437
+ 1. **Unit tests** — `tests/unit/<module>/*.test.ts` — small, fast, one concern
438
+ each.
439
+ 2. **Integration tests** — `tests/integration/tools/<tool>.test.ts` — happy
440
+ path, missing target, duplicate target, dry-run, hash mismatch, permission
441
+ denied, audit-log entry.
442
+ 3. **Property tests** — invariants over fixtures + fuzzed inputs:
443
+ - parse-serialize round-trip preserves AST equivalence;
444
+ - hash is deterministic for canonical input;
445
+ - surgical edits don't change unaffected files' hashes;
446
+ - bulk-apply rollback leaves every file byte-identical to pre-state.
447
+ 4. **Scenario tests** — `tests/scenarios/S*.test.ts` — long multi-step flows
448
+ listed in §12.3.
449
+ 5. **E2E** — spawn CLI subprocess, full JSON-RPC roundtrip; mock-Obsidian
450
+ plugin load + HTTP roundtrip.
451
+
452
+ ### 12.3 Required scenarios
453
+
454
+ S1 mark-tasks-done · S2 refactor-section · S3 rename-heading-update-refs ·
455
+ S4 concurrent-human-edit-conflict · S5 concurrent-unrelated-edit-no-conflict ·
456
+ S6 giant-file-outline-perf · S7 giant-file-surgical-edit-cost ·
457
+ S8 bulk-atomic-rollback · S9 frontmatter-nested-set · S10 code-fence-safety ·
458
+ S11 no-fabrication-on-missing-heading · S12 apply-patch-context-validation ·
459
+ S13 large-vault-search-pagination · S14 edit-then-diff-roundtrip ·
460
+ S15 context-byte-budget-v0-vs-v1.
461
+
462
+ ### 12.4 CI
463
+
464
+ Matrix: `{ubuntu, macos, windows} × {Node 20, 22}`.
465
+
466
+ Coverage gate (c8): fail CI if `src/{markdown,fs,tools,mcp,cache}/*` line
467
+ coverage < 85% or branch coverage < 80%.
468
+
469
+ ---
470
+
471
+ ## 13. Plugin Settings UI
472
+
473
+ - Per-vault enable toggle (unchanged).
474
+ - Display server URL with bearer token; copy button.
475
+ - "Rotate token" button.
476
+ - "Read-only mode" toggle.
477
+ - Collapsible per-tool enable list (defaults all on except `regex.replace` off).
478
+ - Optional per-vault `allow_paths`/`deny_paths` text fields.
479
+ - Audit log tail viewer (last 100 entries, copy-to-clipboard).
480
+ - "Test connection" button — issues `initialize` to its own server.
481
+
482
+ ---
483
+
484
+ ## 14. CLI Surface
485
+
486
+ ```
487
+ obsidian-native-mcp [--read-only] [--config <path>] [--vault <name=path>]...
488
+ ```
489
+
490
+ Config resolution order: `--vault` flags → `--config` JSON → `$OBSIDIAN_VAULT_PATHS` env → `~/.config/obsidian-native-mcp/vaults.json` → Obsidian auto-discovery.
491
+
492
+ ---
493
+
494
+ ## 15. Build Order (executable plan)
495
+
496
+ Each step is a green-tests-required gate.
497
+
498
+ 1. Design freeze → this document committed.
499
+ 2. `package.json` updates: add `mdast-util-from-markdown`,
500
+ `mdast-util-to-markdown`, `mdast-util-gfm`, `mdast-util-frontmatter`,
501
+ `micromark-extension-wiki-link`, `mdast-util-wiki-link`, `yaml`,
502
+ `picomatch`. Move `obsidian` to peerDep where it isn't already.
503
+ 3. `src/utils/types.ts` + `src/markdown/fingerprint.ts` (pure, no deps).
504
+ 4. `src/markdown/parse.ts` + `serialize.ts` (mdast wiring + plugin set).
505
+ 5. `src/markdown/frontmatter.ts` (yaml-lib wrapper) → unit tests vs
506
+ `frontmatter-stress` fixture.
507
+ 6. `src/markdown/headings.ts`, `blocks.ts`, `links.ts`, `tags.ts`,
508
+ `outline.ts` → unit tests vs `code-heavy`, `dup-headings`, `setext`,
509
+ `links-zoo`.
510
+ 7. `src/fs/{io,walk,trash,paths}.ts` async + traversal-safe.
511
+ 8. `src/cache/file-cache.ts` (Design B) + invariants.
512
+ 9. `src/audit/log.ts`.
513
+ 10. `src/vault/{registry,permissions}.ts` + tests for cross-platform discovery.
514
+ 11. `src/mcp/{framing,protocol,server,transport,stdio,http}.ts` with token /
515
+ origin / size-cap / SSE session limits.
516
+ 12. `src/handlers/{register,args}.ts` declarative tool registry + error envelope.
517
+ 13. `src/tools/read/*` — implement all read tools; integration tests each.
518
+ 14. `src/tools/write/*` — implement write tools in this order:
519
+ `file.create`, `file.append`, `file.replace`, `file.move`, `file.delete`,
520
+ `lines.replace`, `lines.insert`, `str_replace`, `apply_edits`,
521
+ `apply_patch`, `heading.replace_body`, `heading.rename`, `block.replace`,
522
+ `block.rename`, `frontmatter.set`, `frontmatter.delete`, `regex.replace`,
523
+ `bulk.apply`.
524
+ 15. Wire `src/cli/index.ts` and `src/plugin/{main,settings}.ts` to the new
525
+ server.
526
+ 16. Build fixtures + property + scenario tests; achieve coverage gates.
527
+ 17. Cross-platform CI matrix + coverage reporting.
528
+ 18. Rewrite `README.md`, `DEVELOPER.md`; write `BREAKING.md` (v0 → v1 tool
529
+ migration table).
530
+ 19. Bump to `1.0.0`; semantic-release ships it.
531
+
532
+ ---
533
+
534
+ ## 16. Non-Goals for v1.0
535
+
536
+ - Local semantic search (post-1.0 feature, requires optional dep, separate package).
537
+ - File-watcher-backed full vault index (post-1.0 — `IIndex` seam is ready).
538
+ - Mobile (Obsidian iOS/Android) — `isDesktopOnly: true` stays.
539
+ - Multi-user / OAuth — local-loopback only.
540
+ - Real-time push notifications to MCP clients beyond standard MCP semantics.
541
+
542
+ ---
543
+
544
+ ## 17. Versioning Policy
545
+
546
+ - Pre-1.0: no backward compatibility promises (we're cutting clean now).
547
+ - 1.0+: semantic versioning. Tool schema changes are SemVer-breaking unless
548
+ additive (new optional fields).
549
+
550
+ ---
551
+
552
+ ## 18. Definition of Done for v1.0
553
+
554
+ A release qualifies as v1.0 only if **all** of:
555
+
556
+ - [ ] Every tool in §6 implemented, schema-validated, with integration tests.
557
+ - [ ] Every scenario in §12.3 green on all CI matrix cells.
558
+ - [ ] Coverage gates met on the targeted modules.
559
+ - [ ] No `any` in tool argument or result types.
560
+ - [ ] Stdio transport actually emits Content-Length framing (lint-style test
561
+ asserts this).
562
+ - [ ] HTTP transport rejects: missing token, wrong token, disallowed Origin,
563
+ body > 5 MB.
564
+ - [ ] Bulk-atomic scenario S8 demonstrates byte-identical rollback.
565
+ - [ ] Scenario S15 demonstrates ≥ 5× context-byte reduction vs v0.x on a
566
+ recorded edit session.
567
+ - [ ] README + DEVELOPER.md + BREAKING.md reflect v1.0 reality.
568
+ - [ ] `npm run lint && npm run format:check && npm test && npm run check &&
569
+ npm run build && npm run build:plugin` all green.
570
+
571
+ ---
572
+
573
+ ## 19. Open Decisions (deferred but tracked)
574
+
575
+ - Whether `outline` should accept `maxDepth` filtering server-side (likely yes).
576
+ - Whether `search.content` should return `lineHash` for every snippet (cost:
577
+ one sha per snippet; benefit: surgical fixup paths).
578
+ - Whether to expose a `cache.stats` admin tool for debugging.
579
+ - Whether `apply_patch` should accept GitHub-style `diff --git` headers or only
580
+ raw hunks.
581
+
582
+ These are recorded so they don't get forgotten; default = yes for the first
583
+ three, raw hunks only for the last.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "obsidian-native-mcp",
3
3
  "name": "Obsidian Native MCP",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "minAppVersion": "1.5.0",
6
6
  "description": "MCP server for AI assistants to read, search, create, and modify notes in your vaults",
7
7
  "author": "Utkarsh Srivastava",
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "obsidian-native-mcp",
3
3
  "name": "Obsidian Native MCP",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "minAppVersion": "1.5.0",
6
6
  "description": "MCP server for AI assistants to read, search, create, and modify notes in your vaults",
7
7
  "author": "Utkarsh Srivastava",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obsidian-native-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Zero-dependency MCP server for Obsidian vaults. Direct filesystem access — no Obsidian process or REST API plugin needed.",
5
5
  "license": "MIT",
6
6
  "type": "module",