obsidian-mcp-server 3.7.2 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/AGENTS.md +20 -15
  2. package/CLAUDE.md +20 -15
  3. package/README.md +31 -19
  4. package/changelog/3.8.x/3.8.0.md +38 -0
  5. package/changelog/3.9.x/3.9.0.md +43 -0
  6. package/changelog/template.md +10 -3
  7. package/dist/config/server-config.d.ts +1 -0
  8. package/dist/config/server-config.d.ts.map +1 -1
  9. package/dist/config/server-config.js +30 -19
  10. package/dist/config/server-config.js.map +1 -1
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.js +27 -15
  13. package/dist/index.js.map +1 -1
  14. package/dist/instructions.d.ts +4 -3
  15. package/dist/instructions.d.ts.map +1 -1
  16. package/dist/instructions.js +23 -12
  17. package/dist/instructions.js.map +1 -1
  18. package/dist/mcp-server/resources/definitions/index.d.ts +2 -2
  19. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts +3 -2
  20. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts.map +1 -1
  21. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js +6 -5
  22. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js.map +1 -1
  23. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.d.ts +2 -2
  24. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js +2 -2
  25. package/dist/mcp-server/resources/definitions/obsidian-vault-note.resource.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +15 -6
  27. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +24 -12
  29. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/index.d.ts +250 -230
  31. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  32. package/dist/mcp-server/tools/definitions/index.js +6 -4
  33. package/dist/mcp-server/tools/definitions/index.js.map +1 -1
  34. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts +8 -2
  35. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +10 -3
  37. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
  38. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +7 -6
  39. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
  40. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +7 -7
  41. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
  42. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +2 -2
  43. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
  44. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +8 -5
  45. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
  46. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +6 -6
  47. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
  48. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +8 -8
  49. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
  50. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts +4 -4
  51. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +7 -7
  52. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
  53. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts +2 -2
  54. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -1
  55. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +2 -2
  56. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -1
  57. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts +2 -2
  58. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.d.ts.map +1 -1
  59. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +2 -2
  60. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
  61. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +2 -2
  62. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +3 -3
  63. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
  64. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts +8 -2
  65. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.d.ts.map +1 -1
  66. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js +10 -3
  67. package/dist/mcp-server/tools/definitions/obsidian-patch-note.tool.js.map +1 -1
  68. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts +2 -2
  69. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +2 -2
  70. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
  71. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts +185 -35
  72. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
  73. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +145 -82
  74. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
  75. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts +8 -2
  76. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.d.ts.map +1 -1
  77. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js +12 -5
  78. package/dist/mcp-server/tools/definitions/obsidian-write-note.tool.js.map +1 -1
  79. package/dist/services/obsidian/jsonlogic-evaluator.d.ts +33 -8
  80. package/dist/services/obsidian/jsonlogic-evaluator.d.ts.map +1 -1
  81. package/dist/services/obsidian/jsonlogic-evaluator.js +171 -32
  82. package/dist/services/obsidian/jsonlogic-evaluator.js.map +1 -1
  83. package/dist/services/obsidian/jsonlogic-worker.d.ts +6 -5
  84. package/dist/services/obsidian/jsonlogic-worker.d.ts.map +1 -1
  85. package/dist/services/obsidian/jsonlogic-worker.js +7 -5
  86. package/dist/services/obsidian/jsonlogic-worker.js.map +1 -1
  87. package/dist/services/obsidian/obsidian-service.d.ts +90 -36
  88. package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
  89. package/dist/services/obsidian/obsidian-service.js +748 -248
  90. package/dist/services/obsidian/obsidian-service.js.map +1 -1
  91. package/dist/services/obsidian/patch-instruction.d.ts +4 -1
  92. package/dist/services/obsidian/patch-instruction.d.ts.map +1 -1
  93. package/dist/services/obsidian/patch-instruction.js +5 -2
  94. package/dist/services/obsidian/patch-instruction.js.map +1 -1
  95. package/dist/services/obsidian/path-policy.d.ts +67 -23
  96. package/dist/services/obsidian/path-policy.d.ts.map +1 -1
  97. package/dist/services/obsidian/path-policy.js +183 -80
  98. package/dist/services/obsidian/path-policy.js.map +1 -1
  99. package/dist/services/obsidian/section-extractor.d.ts +62 -1
  100. package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
  101. package/dist/services/obsidian/section-extractor.js +387 -51
  102. package/dist/services/obsidian/section-extractor.js.map +1 -1
  103. package/dist/services/obsidian/simple-search.d.ts +62 -0
  104. package/dist/services/obsidian/simple-search.d.ts.map +1 -0
  105. package/dist/services/obsidian/simple-search.js +264 -0
  106. package/dist/services/obsidian/simple-search.js.map +1 -0
  107. package/manifest.json +12 -4
  108. package/package.json +9 -9
  109. package/server.json +23 -11
  110. package/skills/obsidian-guide/SKILL.md +61 -0
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @fileoverview The Local REST API's `POST /search/simple/` run in-server, for
3
+ * text searches under a read scope: plugin 5.2.0's `simpleSearch` over
4
+ * Obsidian 1.14.5's `prepareSimpleSearch`. The query is lower-cased and split
5
+ * on single spaces; a note matches when every token occurs in its lower-cased
6
+ * `basename + "\n\n" + body`. Each token's occurrences are found by `indexOf`,
7
+ * resuming one character past the end of the last, so an occurrence that
8
+ * touches or overlaps the previous one is skipped; the occurrences of every
9
+ * token are sorted and merged where they overlap or touch. A merged range
10
+ * inside the basename is a `filename` span whose context is the basename, one
11
+ * inside the body a `content` span with a `contextLength` window either side,
12
+ * widened one code unit rather than split a surrogate pair, and one across the
13
+ * two is dropped. Offsets index the lower-cased text, and windows are cut from
14
+ * the original, as the plugin cuts them. Hits are ordered by Obsidian's score,
15
+ * highest first, ties in the order notes were added (the plugin's vault order).
16
+ * @module services/obsidian/simple-search
17
+ */
18
+ /** A span as `/search/simple/` reports it, plus where `match.start` sits in `context`. */
19
+ export interface SimpleSearchSpan {
20
+ context: string;
21
+ contextStart: number;
22
+ match: {
23
+ end: number;
24
+ source: 'content' | 'filename';
25
+ start: number;
26
+ };
27
+ }
28
+ /** A note's `/search/simple/` hit. */
29
+ export interface SimpleSearchHit {
30
+ filename: string;
31
+ matches: SimpleSearchSpan[];
32
+ score: number;
33
+ }
34
+ /**
35
+ * The query's tokens as `prepareSimpleSearch` reads them: lower-cased, split
36
+ * on single spaces (a tab or newline stays inside its token), empty pieces
37
+ * dropped. Repeats are dropped too: Obsidian scans a repeated token again, but
38
+ * its ranges coincide with the first scan's and merge away.
39
+ */
40
+ export declare function simpleSearchTokens(query: string): string[];
41
+ export declare const isHighSurrogate: (cu: number) => boolean;
42
+ export declare const isLowSurrogate: (cu: number) => boolean;
43
+ /**
44
+ * One text search over notes added in vault order, holding each hit's context
45
+ * windows. `maxResponseChars` bounds the plugin's response for those hits, its
46
+ * length as `JSON.stringify(hits, null, 2)` (see `HIT_FRAME`): every hit's
47
+ * filename and score, every span's offsets and escaped window, and the framing
48
+ * around each. The count grows span by span as each note is added, so the
49
+ * span that passes the bound fails the search with `context_length_too_large`
50
+ * at once, and no more spans are held than a response the plugin could send.
51
+ */
52
+ export declare class SimpleSearch {
53
+ #private;
54
+ /** The distinct non-empty tokens; a note matches only when it holds every one. */
55
+ readonly tokens: readonly string[];
56
+ constructor(query: string, contextLength: number, maxResponseChars?: number);
57
+ /** Match one note, `body` as Obsidian's `cachedRead` gives it. */
58
+ add(path: string, body: string): void;
59
+ /** The hits, highest score first; equal scores keep the order their notes were added. */
60
+ hits(): SimpleSearchHit[];
61
+ }
62
+ //# sourceMappingURL=simple-search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"simple-search.d.ts","sourceRoot":"","sources":["../../../src/services/obsidian/simple-search.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,0FAA0F;AAC1F,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,SAAS,GAAG,UAAU,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CACvE;AAED,sCAAsC;AACtC,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,gBAAgB,EAAE,CAAC;IAC5B,KAAK,EAAE,MAAM,CAAC;CACf;AAoCD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAE1D;AAYD,eAAO,MAAM,eAAe,OAAQ,MAAM,YAAiC,CAAC;AAC5E,eAAO,MAAM,cAAc,OAAQ,MAAM,YAAiC,CAAC;AAkH3E;;;;;;;;GAQG;AACH,qBAAa,YAAY;;IAMvB,kFAAkF;IAClF,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAEnC,YAAY,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,gBAAgB,SAA2B,EAK5F;IAED,kEAAkE;IAClE,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAiDpC;IAYD,yFAAyF;IACzF,IAAI,IAAI,eAAe,EAAE,CAExB;CACF"}
@@ -0,0 +1,264 @@
1
+ /**
2
+ * @fileoverview The Local REST API's `POST /search/simple/` run in-server, for
3
+ * text searches under a read scope: plugin 5.2.0's `simpleSearch` over
4
+ * Obsidian 1.14.5's `prepareSimpleSearch`. The query is lower-cased and split
5
+ * on single spaces; a note matches when every token occurs in its lower-cased
6
+ * `basename + "\n\n" + body`. Each token's occurrences are found by `indexOf`,
7
+ * resuming one character past the end of the last, so an occurrence that
8
+ * touches or overlaps the previous one is skipped; the occurrences of every
9
+ * token are sorted and merged where they overlap or touch. A merged range
10
+ * inside the basename is a `filename` span whose context is the basename, one
11
+ * inside the body a `content` span with a `contextLength` window either side,
12
+ * widened one code unit rather than split a surrogate pair, and one across the
13
+ * two is dropped. Offsets index the lower-cased text, and windows are cut from
14
+ * the original, as the plugin cuts them. Hits are ordered by Obsidian's score,
15
+ * highest first, ties in the order notes were added (the plugin's vault order).
16
+ * @module services/obsidian/simple-search
17
+ */
18
+ import { validationError } from '@cyanheads/mcp-ts-core/errors';
19
+ /**
20
+ * Lengths of the fixed parts of the plugin's response, which its Express app
21
+ * writes with `res.json` under `json spaces` 2, so `JSON.stringify(hits,
22
+ * null, 2)`:
23
+ *
24
+ * ```text
25
+ * [
26
+ * {
27
+ * "filename": "Folder/Note.md",
28
+ * "score": -1.2,
29
+ * "matches": [
30
+ * {
31
+ * "match": {
32
+ * "start": 12,
33
+ * "end": 17,
34
+ * "source": "content"
35
+ * },
36
+ * "context": "…"
37
+ * }
38
+ * ]
39
+ * }
40
+ * ]
41
+ * ```
42
+ *
43
+ * A hit or a span counts its separator, line break, and indentation, and its
44
+ * keys and braces. Its values are counted where they are known.
45
+ */
46
+ const RESPONSE_FRAME = 2;
47
+ const HIT_FRAME = 58;
48
+ /** A `matches` array with no span is `[]`; one with spans adds a line break and indentation before its `]`. */
49
+ const EMPTY_MATCHES = 2;
50
+ const MATCHES_FRAME = 6;
51
+ const SPAN_FRAME = 128;
52
+ /**
53
+ * The query's tokens as `prepareSimpleSearch` reads them: lower-cased, split
54
+ * on single spaces (a tab or newline stays inside its token), empty pieces
55
+ * dropped. Repeats are dropped too: Obsidian scans a repeated token again, but
56
+ * its ranges coincide with the first scan's and merge away.
57
+ */
58
+ export function simpleSearchTokens(query) {
59
+ return [...new Set(query.toLowerCase().split(' ').filter(Boolean))];
60
+ }
61
+ /**
62
+ * Obsidian's note basename (`TFile.basename`): the file name less its last
63
+ * `.extension`, the whole name when its only dot leads or trails it.
64
+ */
65
+ function noteBasename(path) {
66
+ const name = path.slice(path.lastIndexOf('/') + 1);
67
+ const dot = name.lastIndexOf('.');
68
+ return dot <= 0 || dot === name.length - 1 ? name : name.slice(0, dot);
69
+ }
70
+ export const isHighSurrogate = (cu) => cu >= 0xd800 && cu <= 0xdbff;
71
+ export const isLowSurrogate = (cu) => cu >= 0xdc00 && cu <= 0xdfff;
72
+ /**
73
+ * Upstream's `widenToCodePointBoundaries`: grow `[start, end)` by at most one
74
+ * code unit per side so neither edge splits a surrogate pair.
75
+ */
76
+ function widen(text, start, end) {
77
+ let from = start;
78
+ if (from > 0 && from < text.length && isLowSurrogate(text.charCodeAt(from)))
79
+ from -= 1;
80
+ let to = end;
81
+ if (to > 0 && to < text.length && isHighSurrogate(text.charCodeAt(to - 1)))
82
+ to += 1;
83
+ return [from, to];
84
+ }
85
+ /** Characters `JSON.stringify` writes for a whole number: its decimal digits. */
86
+ function digits(n) {
87
+ let count = 1;
88
+ for (let power = 10; power <= n; power *= 10)
89
+ count++;
90
+ return count;
91
+ }
92
+ /**
93
+ * Characters `JSON.stringify` adds escaping `text[i]`: one for `"`, `\`, and
94
+ * the controls with a short escape (`\b \t \n \f \r`), five for any other
95
+ * control or a lone surrogate (`\uXXXX`).
96
+ */
97
+ function escapeOverhead(text, i) {
98
+ const cu = text.charCodeAt(i);
99
+ if (cu === 0x22 || cu === 0x5c)
100
+ return 1;
101
+ if (cu < 0x20)
102
+ return cu >= 0x08 && cu <= 0x0d && cu !== 0x0b ? 1 : 5;
103
+ if (isHighSurrogate(cu))
104
+ return isLowSurrogate(text.charCodeAt(i + 1)) ? 0 : 5;
105
+ if (isLowSurrogate(cu))
106
+ return isHighSurrogate(text.charCodeAt(i - 1)) ? 0 : 5;
107
+ return 0;
108
+ }
109
+ /**
110
+ * Characters `JSON.stringify` adds escaping the window `text.slice(from, to)`,
111
+ * for windows given in note order, where neither edge moves back. Two running
112
+ * sums of `escapeOverhead` over the characters scanned so far, one per edge,
113
+ * give each window as their difference: what windows share is scanned once,
114
+ * and what no window covers is skipped. `escapeOverhead` reads a surrogate's
115
+ * partner in the note; an edge that cuts a pair the note holds whole (`widen`
116
+ * stops one code unit short when surrogates run three or more in a row)
117
+ * leaves that half lone in the window, escaped too.
118
+ */
119
+ function windowEscapes(text) {
120
+ let left = 0;
121
+ let leftSum = 0;
122
+ let right = 0;
123
+ let rightSum = 0;
124
+ return (from, to) => {
125
+ if (from >= to)
126
+ return 0;
127
+ if (from >= right) {
128
+ left = right = from;
129
+ leftSum = rightSum;
130
+ }
131
+ for (; left < from; left++)
132
+ leftSum += escapeOverhead(text, left);
133
+ for (; right < to; right++)
134
+ rightSum += escapeOverhead(text, right);
135
+ let sum = rightSum - leftSum;
136
+ if (isLowSurrogate(text.charCodeAt(from)) && isHighSurrogate(text.charCodeAt(from - 1))) {
137
+ sum += 5;
138
+ }
139
+ if (isHighSurrogate(text.charCodeAt(to - 1)) && isLowSurrogate(text.charCodeAt(to)))
140
+ sum += 5;
141
+ return sum;
142
+ };
143
+ }
144
+ /**
145
+ * The merged ranges `prepareSimpleSearch` reports for `text`, and its score;
146
+ * `undefined` when some token does not occur. With no tokens every text
147
+ * matches, with no ranges and a score of 0.
148
+ */
149
+ function prepareSimpleSearch(tokens, queryLength, text) {
150
+ const lower = text.toLowerCase();
151
+ const found = [];
152
+ for (const token of tokens) {
153
+ const before = found.length;
154
+ for (let at = lower.indexOf(token); at !== -1; at = lower.indexOf(token, at + token.length + 1)) {
155
+ found.push([at, at + token.length]);
156
+ }
157
+ if (found.length === before)
158
+ return;
159
+ }
160
+ // Array sort is stable, and ranges sharing a start merge alike in any order.
161
+ found.sort((a, b) => a[0] - b[0]);
162
+ const ranges = [];
163
+ for (const range of found) {
164
+ const last = ranges.at(-1);
165
+ if (last === undefined || last[1] < range[0])
166
+ ranges.push(range);
167
+ else if (last[1] < range[1])
168
+ last[1] = range[1];
169
+ }
170
+ return { ranges, score: score(ranges, queryLength, text.length) };
171
+ }
172
+ /** Obsidian's simple-search score, with its operations in its order so the result is bit-identical. */
173
+ function score(ranges, queryLength, textLength) {
174
+ const first = ranges[0];
175
+ const last = ranges.at(-1);
176
+ if (first === undefined || last === undefined)
177
+ return 0;
178
+ let s = 0;
179
+ s -= Math.max(0, ranges.length - 1);
180
+ s -= (last[1] - first[0] + 1 - queryLength) / 100;
181
+ s -= first[0] / 1e3;
182
+ s -= textLength / 1e4;
183
+ return s;
184
+ }
185
+ /**
186
+ * One text search over notes added in vault order, holding each hit's context
187
+ * windows. `maxResponseChars` bounds the plugin's response for those hits, its
188
+ * length as `JSON.stringify(hits, null, 2)` (see `HIT_FRAME`): every hit's
189
+ * filename and score, every span's offsets and escaped window, and the framing
190
+ * around each. The count grows span by span as each note is added, so the
191
+ * span that passes the bound fails the search with `context_length_too_large`
192
+ * at once, and no more spans are held than a response the plugin could send.
193
+ */
194
+ export class SimpleSearch {
195
+ #contextLength;
196
+ #hits = [];
197
+ #maxResponseChars;
198
+ #queryLength;
199
+ #responseChars = RESPONSE_FRAME;
200
+ /** The distinct non-empty tokens; a note matches only when it holds every one. */
201
+ tokens;
202
+ constructor(query, contextLength, maxResponseChars = Number.POSITIVE_INFINITY) {
203
+ this.tokens = simpleSearchTokens(query);
204
+ this.#queryLength = query.length;
205
+ this.#contextLength = contextLength;
206
+ this.#maxResponseChars = maxResponseChars;
207
+ }
208
+ /** Match one note, `body` as Obsidian's `cachedRead` gives it. */
209
+ add(path, body) {
210
+ const basename = noteBasename(path);
211
+ const prefix = `${basename}\n\n`;
212
+ const result = prepareSimpleSearch(this.tokens, this.#queryLength, prefix + body);
213
+ if (!result)
214
+ return;
215
+ this.#charge(HIT_FRAME + JSON.stringify(path).length + String(result.score).length + EMPTY_MATCHES);
216
+ const basenameJson = JSON.stringify(basename).length;
217
+ const escapes = windowEscapes(body);
218
+ const matches = [];
219
+ const chargeSpan = (start, end, source, contextJson) => this.#charge((matches.length === 0 ? MATCHES_FRAME - EMPTY_MATCHES : 0) +
220
+ SPAN_FRAME +
221
+ digits(start) +
222
+ digits(end) +
223
+ source.length +
224
+ 2 +
225
+ contextJson);
226
+ for (const [s, e] of result.ranges) {
227
+ if (s < prefix.length && e <= prefix.length) {
228
+ const end = Math.min(e, basename.length);
229
+ chargeSpan(s, end, 'filename', basenameJson);
230
+ matches.push({
231
+ context: basename,
232
+ contextStart: s,
233
+ match: { start: s, end, source: 'filename' },
234
+ });
235
+ }
236
+ else if (s >= prefix.length) {
237
+ const start = s - prefix.length;
238
+ const end = e - prefix.length;
239
+ const [from, widened] = widen(body, Math.max(start - this.#contextLength, 0), end + this.#contextLength);
240
+ const to = Math.min(widened, body.length);
241
+ const context = body.slice(from, to);
242
+ chargeSpan(start, end, 'content', 2 + context.length + escapes(from, to));
243
+ matches.push({
244
+ context,
245
+ contextStart: start - from,
246
+ match: { start, end, source: 'content' },
247
+ });
248
+ }
249
+ }
250
+ this.#hits.push({ filename: path, score: result.score, matches });
251
+ }
252
+ /** Add `chars` to the response, failing the search once it passes `maxResponseChars`. */
253
+ #charge(chars) {
254
+ this.#responseChars += chars;
255
+ if (this.#responseChars <= this.#maxResponseChars)
256
+ return;
257
+ throw validationError(`The matches for this search (contextLength ${this.#contextLength}) would take more than ${this.#maxResponseChars.toLocaleString('en-US')} characters to send across the readable notes, more than one response can carry. Each match costs its context window plus about 150 characters of offsets and framing, so the total tracks the number of matches as well as contextLength.`, { reason: 'context_length_too_large', contextLength: this.#contextLength });
258
+ }
259
+ /** The hits, highest score first; equal scores keep the order their notes were added. */
260
+ hits() {
261
+ return this.#hits.toSorted((a, b) => b.score - a.score);
262
+ }
263
+ }
264
+ //# sourceMappingURL=simple-search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"simple-search.js","sourceRoot":"","sources":["../../../src/services/obsidian/simple-search.ts"],"names":[],"mappings":"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"}
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "obsidian-mcp-server",
4
- "version": "3.7.2",
4
+ "version": "3.9.0",
5
5
  "description": "Read, write, search, and surgically edit Obsidian vault notes, tags, and frontmatter via MCP. STDIO or Streamable HTTP.",
6
6
  "author": {
7
7
  "name": "cyanheads",
@@ -31,6 +31,7 @@
31
31
  "OBSIDIAN_ENABLE_COMMANDS": "${user_config.OBSIDIAN_ENABLE_COMMANDS}",
32
32
  "OBSIDIAN_READ_PATHS": "${user_config.OBSIDIAN_READ_PATHS}",
33
33
  "OBSIDIAN_WRITE_PATHS": "${user_config.OBSIDIAN_WRITE_PATHS}",
34
+ "OBSIDIAN_DENY_PATHS": "${user_config.OBSIDIAN_DENY_PATHS}",
34
35
  "OBSIDIAN_READ_ONLY": "${user_config.OBSIDIAN_READ_ONLY}",
35
36
  "OBSIDIAN_DELETE_ELICITATION": "${user_config.OBSIDIAN_DELETE_ELICITATION}",
36
37
  "OBSIDIAN_OMNISEARCH_URL": "${user_config.OBSIDIAN_OMNISEARCH_URL}",
@@ -68,7 +69,7 @@
68
69
  },
69
70
  "OBSIDIAN_REQUEST_TIMEOUT_MS": {
70
71
  "title": "Request Timeout (ms)",
71
- "description": "Per-request timeout in milliseconds. Default: 30000.",
72
+ "description": "Milliseconds one request attempt may take before it fails with obsidian_timeout; GET, PUT, and DELETE retry it, and all the requests of one tool call share a 45 s budget, or this value when it is longer. Default: 30000.",
72
73
  "type": "string",
73
74
  "required": false,
74
75
  "default": ""
@@ -89,7 +90,14 @@
89
90
  },
90
91
  "OBSIDIAN_WRITE_PATHS": {
91
92
  "title": "Write Path Allowlist",
92
- "description": "Comma-separated vault-relative folder allowlist for writes. Unset = full vault, or the read scope when reads are restricted. Example: 'projects/,scratch/'.",
93
+ "description": "Comma-separated vault-relative folder allowlist for writes. Unset = full vault, or the read scope when reads are restricted; paths in OBSIDIAN_DENY_PATHS stay denied. Example: 'projects/,scratch/'.",
94
+ "type": "string",
95
+ "required": false,
96
+ "default": ""
97
+ },
98
+ "OBSIDIAN_DENY_PATHS": {
99
+ "title": "Denied Paths",
100
+ "description": "Comma-separated vault-relative paths withheld from reads and writes, overriding both allowlists. Unset = nothing denied. Example: 'private/,journal/therapy/'.",
93
101
  "type": "string",
94
102
  "required": false,
95
103
  "default": ""
@@ -110,7 +118,7 @@
110
118
  },
111
119
  "OBSIDIAN_OMNISEARCH_URL": {
112
120
  "title": "Omnisearch URL",
113
- "description": "Override URL for the Omnisearch plugin HTTP server (port 51361 by default). Set only if the plugin is not on the default port.",
121
+ "description": "Override URL for the Omnisearch plugin HTTP server (port 51361 by default). Set only if the plugin is not on the default port. Under read or deny paths it is never called and the omnisearch search mode is withheld.",
114
122
  "type": "string",
115
123
  "required": false,
116
124
  "default": ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obsidian-mcp-server",
3
- "version": "3.7.2",
3
+ "version": "3.9.0",
4
4
  "mcpName": "io.github.cyanheads/obsidian-mcp-server",
5
5
  "description": "Read, write, search, and surgically edit Obsidian vault notes, tags, and frontmatter via MCP. STDIO or Streamable HTTP.",
6
6
  "type": "module",
@@ -83,7 +83,7 @@
83
83
  },
84
84
  "license": "Apache-2.0",
85
85
  "engines": {
86
- "bun": ">=1.4.0",
86
+ "bun": ">=1.4.4",
87
87
  "node": ">=24.0.0"
88
88
  },
89
89
  "packageManager": "bun@1.4.4",
@@ -91,25 +91,25 @@
91
91
  "access": "public"
92
92
  },
93
93
  "dependencies": {
94
- "@cyanheads/mcp-ts-core": "^0.13.18",
94
+ "@cyanheads/mcp-ts-core": "^0.13.22",
95
95
  "glob-to-regexp": "0.4.1",
96
96
  "js-yaml": "^5.5.0",
97
97
  "json-logic-js": "2.0.5",
98
98
  "marked": "17.0.6",
99
99
  "pino-pretty": "^13.2.0",
100
- "undici": "^8.12.3",
100
+ "undici": "^8.12.4",
101
101
  "yaml": "^2.9.1",
102
102
  "zod": "^4.6.7"
103
103
  },
104
104
  "devDependencies": {
105
- "@biomejs/biome": "^2.6.3",
105
+ "@biomejs/biome": "^2.6.6",
106
106
  "@socketsecurity/bun-security-scanner": "^1.1.3",
107
- "@types/node": "^26.6.5",
108
- "@vitest/coverage-istanbul": "^5.0.5",
107
+ "@types/node": "^26.6.6",
108
+ "@vitest/coverage-istanbul": "^5.1.0",
109
109
  "depcheck": "^1.4.7",
110
110
  "ignore": "^7.0.14",
111
- "tsc-alias": "^1.9.9",
111
+ "tsc-alias": "^1.9.10",
112
112
  "typescript": "^7.0.2",
113
- "vitest": "^5.0.5"
113
+ "vitest": "^5.1.0"
114
114
  }
115
115
  }
package/server.json CHANGED
@@ -6,14 +6,14 @@
6
6
  "url": "https://github.com/cyanheads/obsidian-mcp-server",
7
7
  "source": "github"
8
8
  },
9
- "version": "3.7.2",
9
+ "version": "3.9.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "registryBaseUrl": "https://registry.npmjs.org",
14
14
  "identifier": "obsidian-mcp-server",
15
15
  "runtimeHint": "npx",
16
- "version": "3.7.2",
16
+ "version": "3.9.0",
17
17
  "environmentVariables": [
18
18
  {
19
19
  "name": "OBSIDIAN_API_KEY",
@@ -37,7 +37,7 @@
37
37
  },
38
38
  {
39
39
  "name": "OBSIDIAN_REQUEST_TIMEOUT_MS",
40
- "description": "Per-request timeout in milliseconds.",
40
+ "description": "Milliseconds one request attempt (headers and body) may take before it fails with obsidian_timeout. GET, PUT, and DELETE retry a timed-out attempt; all the requests of one tool call share a 45 s budget, or this value when it is longer.",
41
41
  "format": "string",
42
42
  "isRequired": false,
43
43
  "default": "30000"
@@ -51,13 +51,19 @@
51
51
  },
52
52
  {
53
53
  "name": "OBSIDIAN_READ_PATHS",
54
- "description": "Optional comma-separated vault-relative folder allowlist for reads. Prefix-based with implicit recursion; case-insensitive; trailing slashes normalized. Unset = full vault. Write paths are implicitly readable. Example: 'public/,projects/'.",
54
+ "description": "Optional comma-separated vault-relative folder allowlist for reads. Prefix-based with implicit recursion; case-insensitive; backslashes read as slashes, repeated and trailing slashes and '.' segments dropped, and names normalized to Unicode NFC; an entry naming the vault root ('.') is rejected at startup. Unset = full vault. Write paths are implicitly readable. Example: 'public/,projects/'.",
55
55
  "format": "string",
56
56
  "isRequired": false
57
57
  },
58
58
  {
59
59
  "name": "OBSIDIAN_WRITE_PATHS",
60
- "description": "Optional comma-separated vault-relative folder allowlist for writes. Same syntax as OBSIDIAN_READ_PATHS. Unset = full vault, or the read scope when reads are restricted. Example: 'projects/,scratch/'.",
60
+ "description": "Optional comma-separated vault-relative folder allowlist for writes. Same syntax as OBSIDIAN_READ_PATHS. Unset = full vault, or the read scope when reads are restricted; paths in OBSIDIAN_DENY_PATHS stay denied. Example: 'projects/,scratch/'.",
61
+ "format": "string",
62
+ "isRequired": false
63
+ },
64
+ {
65
+ "name": "OBSIDIAN_DENY_PATHS",
66
+ "description": "Optional comma-separated vault-relative paths withheld from reads and writes. Same syntax as OBSIDIAN_READ_PATHS. Wins over both allowlists; denied paths are left out of listings, search results, and tag counts. Unset = nothing denied. Example: 'private/,journal/therapy/'.",
61
67
  "format": "string",
62
68
  "isRequired": false
63
69
  },
@@ -77,7 +83,7 @@
77
83
  },
78
84
  {
79
85
  "name": "OBSIDIAN_OMNISEARCH_URL",
80
- "description": "Override URL for the Omnisearch plugin HTTP server. Unset derives from OBSIDIAN_BASE_URL host with port 51361. Required only if the Omnisearch plugin is not on the default port.",
86
+ "description": "Override URL for the Omnisearch plugin HTTP server. Unset derives from OBSIDIAN_BASE_URL host with port 51361. Required only if the Omnisearch plugin is not on the default port. Under OBSIDIAN_READ_PATHS or OBSIDIAN_DENY_PATHS the URL is never called and the omnisearch search mode is withheld.",
81
87
  "format": "string",
82
88
  "isRequired": false
83
89
  },
@@ -98,7 +104,7 @@
98
104
  "registryBaseUrl": "https://registry.npmjs.org",
99
105
  "identifier": "obsidian-mcp-server",
100
106
  "runtimeHint": "npx",
101
- "version": "3.7.2",
107
+ "version": "3.9.0",
102
108
  "environmentVariables": [
103
109
  {
104
110
  "name": "MCP_TRANSPORT_TYPE",
@@ -128,7 +134,7 @@
128
134
  },
129
135
  {
130
136
  "name": "OBSIDIAN_REQUEST_TIMEOUT_MS",
131
- "description": "Per-request timeout in milliseconds.",
137
+ "description": "Milliseconds one request attempt (headers and body) may take before it fails with obsidian_timeout. GET, PUT, and DELETE retry a timed-out attempt; all the requests of one tool call share a 45 s budget, or this value when it is longer.",
132
138
  "format": "string",
133
139
  "isRequired": false,
134
140
  "default": "30000"
@@ -142,13 +148,19 @@
142
148
  },
143
149
  {
144
150
  "name": "OBSIDIAN_READ_PATHS",
145
- "description": "Optional comma-separated vault-relative folder allowlist for reads. Prefix-based with implicit recursion; case-insensitive; trailing slashes normalized. Unset = full vault. Write paths are implicitly readable. Example: 'public/,projects/'.",
151
+ "description": "Optional comma-separated vault-relative folder allowlist for reads. Prefix-based with implicit recursion; case-insensitive; backslashes read as slashes, repeated and trailing slashes and '.' segments dropped, and names normalized to Unicode NFC; an entry naming the vault root ('.') is rejected at startup. Unset = full vault. Write paths are implicitly readable. Example: 'public/,projects/'.",
146
152
  "format": "string",
147
153
  "isRequired": false
148
154
  },
149
155
  {
150
156
  "name": "OBSIDIAN_WRITE_PATHS",
151
- "description": "Optional comma-separated vault-relative folder allowlist for writes. Same syntax as OBSIDIAN_READ_PATHS. Unset = full vault, or the read scope when reads are restricted. Example: 'projects/,scratch/'.",
157
+ "description": "Optional comma-separated vault-relative folder allowlist for writes. Same syntax as OBSIDIAN_READ_PATHS. Unset = full vault, or the read scope when reads are restricted; paths in OBSIDIAN_DENY_PATHS stay denied. Example: 'projects/,scratch/'.",
158
+ "format": "string",
159
+ "isRequired": false
160
+ },
161
+ {
162
+ "name": "OBSIDIAN_DENY_PATHS",
163
+ "description": "Optional comma-separated vault-relative paths withheld from reads and writes. Same syntax as OBSIDIAN_READ_PATHS. Wins over both allowlists; denied paths are left out of listings, search results, and tag counts. Unset = nothing denied. Example: 'private/,journal/therapy/'.",
152
164
  "format": "string",
153
165
  "isRequired": false
154
166
  },
@@ -168,7 +180,7 @@
168
180
  },
169
181
  {
170
182
  "name": "OBSIDIAN_OMNISEARCH_URL",
171
- "description": "Override URL for the Omnisearch plugin HTTP server. Unset derives from OBSIDIAN_BASE_URL host with port 51361. Required only if the Omnisearch plugin is not on the default port.",
183
+ "description": "Override URL for the Omnisearch plugin HTTP server. Unset derives from OBSIDIAN_BASE_URL host with port 51361. Required only if the Omnisearch plugin is not on the default port. Under OBSIDIAN_READ_PATHS or OBSIDIAN_DENY_PATHS the URL is never called and the omnisearch search mode is withheld.",
172
184
  "format": "string",
173
185
  "isRequired": false
174
186
  },
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: obsidian-guide
3
+ description: "Gates and multi-call workflows for the obsidian_* tools: pin active and periodic targets to a path, round-trip heading sections without editing the wrong one or dropping its sub-sections, page a search to the end before editing its hits, match tags exactly, and repeat writes after a timeout or a dropped connection. Use before editing several notes, restructuring headings, writing to a periodic note, retagging, moving a note, or retrying a failed obsidian_* write."
4
+ license: Apache-2.0
5
+ metadata:
6
+ author: cyanheads
7
+ version: "1.0"
8
+ ---
9
+
10
+ ## Scope
11
+
12
+ One Obsidian vault, reached through the Local REST API plugin while Obsidian runs. Each note tool reads or edits one note per call. No tool moves or renames a note, and none rewrites links in other notes. The multi-note jobs are the workflows below.
13
+
14
+ ## Identifiers
15
+
16
+ - **Note path**: `hits[].filename` from obsidian_search_notes, `entries[].path` from obsidian_list_notes, and the `path` every note tool returns. Writes and deletes match it exactly, letter case included.
17
+ - **Heading locator**: the full `Parent::Child` paths in obsidian_get_note's `document-map` `headings`, and the `sectionTarget` a section read or write returns.
18
+ - **Tag**: no leading `#`. A note's own `tags` (obsidian_get_note `full`, the JSONLogic `tags` field) hold its tags as written, letter case included. Parent tags show up only in tag counts.
19
+
20
+ ## Gates
21
+
22
+ 1. **Pin `active` and `periodic` targets after the first call.** Each call resolves them again. `active` is whichever file has focus in Obsidian at that moment, and a `periodic` target with no `date` is the current period. For the rest of the task, address the note by the `path` the first call returned.
23
+ 2. **A missing periodic note can't be created through a `periodic` target.** Every tool reads the target before acting, so a write to a period with no note also fails `periodic_not_found`. Ask the user to create it in Obsidian. Or, if they agree, write it by path: read an earlier period's note (pass `date`) to learn the folder and filename pattern. A note written this way holds only what you send, without the user's periodic-note template. On Local REST API 5.0.2 and later without the periodic-notes extension, every `periodic` target fails `periodic_unsupported`, whether or not the note exists; find the note with obsidian_search_notes or obsidian_list_notes and address it by path.
24
+ 3. **Write a heading back with the `sectionTarget` the read returned, never the bare name you sent.** A bare name resolves differently on each side. A read takes the first heading with that text in document order. A write takes the top-level heading with that text when there is one. Reading `Notes` and then writing `Notes` can touch two different sections.
25
+ 4. **A heading section is its whole subtree.** obsidian_get_note `format: "section"` returns the heading line, its body, and every sub-section under it. A replace rewrites all of that, so send the sub-sections back or target the child heading instead. An append lands below the last sub-section. A prepend lands right under the heading line. Round-trip a read through obsidian_write_note with `section`, which drops the leading heading line. obsidian_patch_note `replace` keeps a leading `#` heading line, and plugin v5.0 and later refuse that with `heading_outside_section`.
26
+ 5. **Page a search to the end before editing its hits.** Without OBSIDIAN_READ_PATHS or OBSIDIAN_DENY_PATHS, every page re-runs the search. The cursor is an offset into the new result, so edits that remove matches shift later pages and skip notes. Under a read scope, later pages come from the first page's hits for at most 5 minutes past the last page read. Collect every `filename` first, then edit.
27
+ 6. **A text hit doesn't promise a replace match.** Text mode needs each token somewhere in the filename or body, in any case. obsidian_replace_in_note needs the exact string, case-sensitive by default, and searches the body only by default. Read each note's `perReplacement[].count`: `0` is a success that wrote nothing. `useRegex` patterns run without the `m` flag, so `^` and `$` match the ends of the scoped text, not of a line. To anchor a line, use `(?<=^|\n)` and `(?=\n|$)`.
28
+ 7. **Tags match exactly.** `{"in": ["work", {"var": "tags"}]}` misses notes tagged only `Work`, or only `work/tasks`, which obsidian_list_tags still counts under `work`. To catch nested tags, use `{"some": [{"var": "tags"}, {"regexp": ["^work(/|$)", {"var": ""}]}]}`. obsidian_manage_tags `remove` is exact the same way, and its default `location: "frontmatter"` leaves inline `#tag`s in the body. Pass `location: "both"` and check the returned `tags`.
29
+ 8. **A JSONLogic hit is a note whose tree returns something truthy**, and that value is the hit's `result`. A note whose tree returns `false`, `null`, `0`, `""`, `[]`, or `{}` is left out. Return the field you need and one search reads it from every match: `{"if": [<test>, {"var": "tags"}, null]}` gives each match's tags. An empty value still drops the note.
30
+ 9. **After `obsidian_timeout` or `obsidian_unreachable` on a write, assume it may have landed.** One write call makes several requests under one shared budget (45 s at the default timeout): a read before the write, the write, and a read after. The error can arrive after the write applied, and the server never re-sends a whole-file append or a section write. Repeating is safe for obsidian_write_note, obsidian_patch_note `replace`, obsidian_manage_frontmatter, and obsidian_manage_tags. On a retry, `file_exists` (a create), `note_missing` (a delete), and `content_preexists` (a section append or prepend sent without `applyIfContentPreexists`) each mean the first attempt landed. Don't force a `content_preexists` through with `applyIfContentPreexists`. Read the note before repeating a whole-file obsidian_append_to_note, or an obsidian_replace_in_note whose `replace` still contains its `search`.
31
+ 10. **Confirm before deleting.** obsidian_delete_note deletes on the first call unless its description says it asks. Get the user's go-ahead on the exact path first.
32
+
33
+ ## Workflows
34
+
35
+ **Capture into a periodic note.** Call obsidian_append_to_note with the `periodic` target. Add `section` to land under one of the note's headings, and `createTargetIfMissing: true` if that heading may not exist yet. On `periodic_not_found` or `periodic_unsupported`, follow gate 2. Follow-up edits use the returned `path` (gate 1).
36
+
37
+ **Restructure headings.**
38
+
39
+ 1. Call obsidian_get_note `document-map` for every full heading path.
40
+ 2. Read each section you'll change by its full path, edit it, and write it back through obsidian_write_note `section` (gates 3–4).
41
+ 3. Rename or remove a heading line with obsidian_replace_in_note. No section write changes a heading line.
42
+ 4. To reorder or move whole sections, rewrite the note: obsidian_get_note `content`, edit, then obsidian_write_note `overwrite: true`. Compare `previousSizeInBytes` with `currentSizeInBytes`.
43
+ 5. A renamed heading breaks `[[Note#Old heading]]` links in other notes. Fix them as in *Move or rename a note*.
44
+
45
+ **Find, then edit across notes.**
46
+
47
+ 1. Search and page to the end (gate 5). To find notes by filename anywhere in the vault, test `path` with `regexp` in jsonlogic mode, e.g. `{"regexp": ["(^|/)Meeting[^/]*\\.md$", {"var": "path"}]}`. Don't walk obsidian_list_notes for this: its `nameRegex` never enters a folder whose own name fails the pattern.
48
+ 2. When `instructions` gives a narrower Writable scope than Readable, drop hits outside the Writable scope before writing.
49
+ 3. Edit each hit and read its counts (gate 6).
50
+
51
+ **Retag notes.**
52
+
53
+ 1. Find the notes with a tree that returns their tags (gates 7–8).
54
+ 2. For each note, call obsidian_manage_tags `add` with the new tag and `remove` with the old one, both with `location: "both"`.
55
+ 3. Confirm with obsidian_list_tags `nameRegex`. A parent tag's count includes its nested tags.
56
+
57
+ **Move or rename a note.** No tool does this, and nothing updates links that point at the old name.
58
+
59
+ 1. Read the note with obsidian_get_note `content` and write it with obsidian_write_note at the new path. That write fails with `file_exists` rather than overwrite a note already there.
60
+ 2. Find the notes that wikilink the old name with the backlink `regexp` in obsidian_search_notes' `logic` description. Rewrite each link with obsidian_replace_in_note (gate 6).
61
+ 3. Delete the old note once the user agrees (gate 10).