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.
- package/.github/workflows/ci.yml +11 -11
- package/.github/workflows/release.yml +4 -4
- package/CHANGELOG.md +2 -2
- package/DESIGN_V1.md +583 -0
- package/dist/plugin/manifest.json +1 -1
- package/manifest.json +1 -1
- package/package.json +1 -1
package/.github/workflows/ci.yml
CHANGED
|
@@ -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@
|
|
24
|
-
- uses: actions/setup-node@
|
|
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@
|
|
37
|
-
- uses: actions/setup-node@
|
|
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@
|
|
51
|
-
- uses: actions/setup-node@
|
|
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@
|
|
65
|
-
- uses: actions/setup-node@
|
|
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@
|
|
79
|
-
- uses: actions/setup-node@
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
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.
|
|
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 ([
|
|
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.
|
|
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.
|
|
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