obsidian-mcp-server 3.6.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/AGENTS.md +40 -27
  2. package/CLAUDE.md +40 -27
  3. package/Dockerfile +90 -47
  4. package/README.md +130 -151
  5. package/changelog/3.6.x/3.6.1.md +29 -0
  6. package/changelog/3.7.x/3.7.0.md +34 -0
  7. package/dist/config/server-config.d.ts +1 -0
  8. package/dist/config/server-config.d.ts.map +1 -1
  9. package/dist/config/server-config.js +4 -0
  10. package/dist/config/server-config.js.map +1 -1
  11. package/dist/index.js +30 -10
  12. package/dist/index.js.map +1 -1
  13. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts +7 -5
  14. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.d.ts.map +1 -1
  15. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js +12 -8
  16. package/dist/mcp-server/resources/definitions/obsidian-tags.resource.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts +32 -10
  18. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js +30 -8
  20. package/dist/mcp-server/tools/definitions/_shared/suggest-paths.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/index.d.ts +230 -294
  22. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/index.js +9 -3
  24. package/dist/mcp-server/tools/definitions/index.js.map +1 -1
  25. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js +1 -1
  26. package/dist/mcp-server/tools/definitions/obsidian-append-to-note.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts +108 -10
  28. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.d.ts.map +1 -1
  29. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js +254 -133
  30. package/dist/mcp-server/tools/definitions/obsidian-delete-note.tool.js.map +1 -1
  31. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts +1 -0
  32. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.d.ts.map +1 -1
  33. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js +62 -21
  34. package/dist/mcp-server/tools/definitions/obsidian-get-note.tool.js.map +1 -1
  35. package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.d.ts.map +1 -1
  36. package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.js +1 -2
  37. package/dist/mcp-server/tools/definitions/obsidian-list-commands.tool.js.map +1 -1
  38. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts +6 -1
  39. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.d.ts.map +1 -1
  40. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js +9 -12
  41. package/dist/mcp-server/tools/definitions/obsidian-list-notes.tool.js.map +1 -1
  42. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts +7 -5
  43. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.d.ts.map +1 -1
  44. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js +14 -9
  45. package/dist/mcp-server/tools/definitions/obsidian-list-tags.tool.js.map +1 -1
  46. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.d.ts.map +1 -1
  47. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js +1 -2
  48. package/dist/mcp-server/tools/definitions/obsidian-manage-frontmatter.tool.js.map +1 -1
  49. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js +2 -2
  50. package/dist/mcp-server/tools/definitions/obsidian-manage-tags.tool.js.map +1 -1
  51. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts +4 -1
  52. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.d.ts.map +1 -1
  53. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js +15 -4
  54. package/dist/mcp-server/tools/definitions/obsidian-open-in-ui.tool.js.map +1 -1
  55. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.d.ts.map +1 -1
  56. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js +2 -3
  57. package/dist/mcp-server/tools/definitions/obsidian-replace-in-note.tool.js.map +1 -1
  58. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.d.ts.map +1 -1
  59. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js +2 -5
  60. package/dist/mcp-server/tools/definitions/obsidian-search-notes.tool.js.map +1 -1
  61. package/dist/services/obsidian/obsidian-service.d.ts +7 -0
  62. package/dist/services/obsidian/obsidian-service.d.ts.map +1 -1
  63. package/dist/services/obsidian/obsidian-service.js +72 -35
  64. package/dist/services/obsidian/obsidian-service.js.map +1 -1
  65. package/dist/services/obsidian/patch-instruction.d.ts +20 -1
  66. package/dist/services/obsidian/patch-instruction.d.ts.map +1 -1
  67. package/dist/services/obsidian/patch-instruction.js +30 -0
  68. package/dist/services/obsidian/patch-instruction.js.map +1 -1
  69. package/dist/services/obsidian/path-policy.d.ts +23 -1
  70. package/dist/services/obsidian/path-policy.d.ts.map +1 -1
  71. package/dist/services/obsidian/path-policy.js +45 -3
  72. package/dist/services/obsidian/path-policy.js.map +1 -1
  73. package/dist/services/obsidian/section-extractor.d.ts +24 -0
  74. package/dist/services/obsidian/section-extractor.d.ts.map +1 -1
  75. package/dist/services/obsidian/section-extractor.js +37 -0
  76. package/dist/services/obsidian/section-extractor.js.map +1 -1
  77. package/manifest.json +9 -1
  78. package/package.json +13 -10
  79. package/server.json +24 -24
@@ -1,146 +1,267 @@
1
1
  /**
2
- * @fileoverview obsidian_delete_note — permanently delete a note. Suspends via
2
+ * @fileoverview obsidian_delete_note — permanently delete a note. Built by a
3
+ * factory because the confirmation is an operator choice
4
+ * (`OBSIDIAN_DELETE_ELICITATION`, read in the entry point): off, the handler
5
+ * checks write scope, reads the note, and deletes. On, it suspends via
3
6
  * `ctx.requestInput` to confirm with the user before the DELETE, and is
4
- * re-entered with the answer on `ctx.inputs`.
7
+ * re-entered with the answer on `ctx.inputs`; the answer counts only on a
8
+ * round that redeems the consent record this handler stored when it asked.
5
9
  * @module mcp-server/tools/definitions/obsidian-delete-note.tool
6
10
  */
11
+ import { createHash, randomUUID } from 'node:crypto';
12
+ import { isDeepStrictEqual } from 'node:util';
7
13
  import { inputRequired, tool, z } from '@cyanheads/mcp-ts-core';
8
- import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
14
+ import { JsonRpcErrorCode, McpError, notFound } from '@cyanheads/mcp-ts-core/errors';
9
15
  import { getObsidianService } from '../../../services/obsidian/obsidian-service.js';
10
16
  import { TargetSchema } from './_shared/schemas.js';
17
+ import { findSimilarPaths } from './_shared/suggest-paths.js';
18
+ const OPERATION = 'obsidian_delete_note';
19
+ const CONSENT_TTL_SECONDS = 600;
20
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
11
21
  /** Answered by the client in the confirmation round; re-validated on re-entry. */
12
22
  const DeleteConfirmation = z.object({
13
23
  confirm: z.boolean().describe('Set to true to delete the note. Any other value cancels.'),
14
24
  });
15
- export const obsidianDeleteNote = tool('obsidian_delete_note', {
16
- description: 'Permanently delete a note from the vault. Asks the user to confirm before deleting — the call is answered with a confirmation request and retried with the answer. Recovery requires the local trash in Obsidian — there is no API-level undo.',
17
- annotations: { destructiveHint: true },
18
- input: z.object({
19
- target: TargetSchema.describe('Which note to delete.'),
20
- }),
21
- output: z.object({
22
- path: z.string().describe('Resolved vault-relative path of the deleted note.'),
23
- deleted: z.boolean().describe('True when the file was removed.'),
24
- previousSizeInBytes: z
25
- .number()
26
- .describe('Byte size of the note immediately before deletion. Confirms the size of what was removed.'),
27
- currentSizeInBytes: z
28
- .number()
29
- .describe('Always 0 after a successful delete — the file no longer exists.'),
30
- }),
31
- auth: ['tool:obsidian_delete_note:write'],
32
- errors: [
33
- {
34
- reason: 'path_forbidden',
35
- thrownBy: 'service',
36
- code: JsonRpcErrorCode.Forbidden,
37
- when: 'The target path is outside OBSIDIAN_WRITE_PATHS, or OBSIDIAN_READ_ONLY=true denies all writes.',
38
- recovery: 'Use a path inside the configured write scope. The error data echoes the active scope.',
39
- },
40
- {
41
- reason: 'cancelled',
42
- code: JsonRpcErrorCode.InvalidRequest,
43
- when: 'User declined, cancelled, or answered false to the confirmation request.',
44
- severity: 'notice',
45
- recovery: 'Re-run the tool when the user is ready to confirm deletion.',
46
- },
47
- {
48
- reason: 'note_missing',
49
- thrownBy: 'service',
50
- code: JsonRpcErrorCode.NotFound,
51
- when: 'The vault path does not resolve to an existing note.',
52
- recovery: 'Verify the path with obsidian_list_notes or use obsidian_search_notes to locate the note.',
53
- },
54
- {
55
- reason: 'no_active_file',
56
- thrownBy: 'service',
57
- code: JsonRpcErrorCode.NotFound,
58
- when: 'Target was `active` but no file is currently open in Obsidian.',
59
- recovery: 'Call obsidian_open_in_ui to focus a file, or pass an explicit path target instead.',
60
- },
61
- {
62
- reason: 'periodic_unsupported',
63
- thrownBy: 'service',
64
- code: JsonRpcErrorCode.NotFound,
65
- when: 'Target was `periodic` and this vault runs Local REST API v5.0.2 or later without the companion periodic-notes extension, so the `/periodic/` routes are not served at all.',
66
- recovery: 'Install the periodic-notes extension from https://github.com/coddingtonbear/obsidian-local-rest-api-periodic-notes, or address the note by an explicit vault path.',
67
- },
68
- {
69
- reason: 'periodic_not_found',
70
- thrownBy: 'service',
71
- code: JsonRpcErrorCode.NotFound,
72
- when: 'Target was `periodic`, the `/periodic/` routes are served on this vault, and no note exists for the requested period.',
73
- recovery: 'Pass an explicit path target — periodic notes must already exist.',
74
- },
75
- {
76
- reason: 'periodic_disabled',
77
- thrownBy: 'service',
78
- code: JsonRpcErrorCode.ValidationError,
79
- when: "Target was `periodic` but the requested period is not enabled in Obsidian's Periodic Notes plugin settings.",
80
- recovery: "Pass an explicit path target — the requested period is disabled in the operator's Periodic Notes plugin.",
81
- },
82
- {
83
- reason: 'path_is_directory',
84
- thrownBy: 'service',
85
- code: JsonRpcErrorCode.ValidationError,
86
- when: 'The supplied path names a folder rather than a note file. Deleting a folder is not offered — the upstream removes it and everything inside it in one unrecoverable step.',
87
- recovery: 'Call obsidian_list_notes with this path to list the folder, then delete files one at a time by their full paths.',
88
- },
89
- {
90
- reason: 'path_traversal',
91
- thrownBy: 'service',
92
- code: JsonRpcErrorCode.ValidationError,
93
- when: 'The path contains a `.` or `..` segment, which is rejected to prevent vault escape.',
94
- recovery: 'Supply a vault-relative path with no `.` or `..` segments, e.g. "Projects/Note.md". Use obsidian_list_notes to browse the vault.',
95
- },
96
- ],
97
- async handler(input, ctx) {
98
- const svc = getObsidianService();
99
- const path = await svc.resolvePath(ctx, input.target);
100
- const pathTarget = { type: 'path', path };
101
- /**
102
- * Probe size before asking for confirmation so the user sees how much
103
- * they're about to destroy. Throws `note_missing` if the file is already
104
- * gone — preempts a confusing post-confirmation DELETE 404. The probe runs
105
- * again on re-entry, so the size is re-verified against the answer round.
106
- */
107
- const previousSizeInBytes = await svc.getSize(ctx, pathTarget);
108
- /**
109
- * A declined or cancelled prompt is a dead end, not a round to retry —
110
- * re-asking would burn the round budget until the client gives up.
111
- */
112
- const view = ctx.inputs.view('confirm');
113
- if (view.kind === 'elicit' && view.action !== 'accept') {
114
- throw ctx.fail('cancelled', `User sent '${view.action}' for the deletion confirmation.`, {
115
- path,
116
- ...ctx.recoveryFor('cancelled'),
117
- });
118
- }
119
- const answer = ctx.inputs.accepted('confirm', DeleteConfirmation);
120
- if (!answer) {
121
- return ctx.requestInput({
122
- inputRequests: {
123
- confirm: inputRequired.elicit({
124
- message: `Permanently delete '${path}' (${previousSizeInBytes} bytes)? This cannot be undone via the API; recovery would require Obsidian's local trash.`,
125
- requestedSchema: DeleteConfirmation,
126
- }),
127
- },
128
- });
129
- }
130
- if (!answer.confirm) {
131
- throw ctx.fail('cancelled', 'Deletion cancelled by user.', {
132
- path,
133
- ...ctx.recoveryFor('cancelled'),
134
- });
135
- }
136
- await svc.deleteNote(ctx, pathTarget);
137
- return { path, deleted: true, previousSizeInBytes, currentSizeInBytes: 0 };
138
- },
139
- format: (result) => [
140
- {
141
- type: 'text',
142
- text: `**Deleted ${result.path}** (size: ${result.previousSizeInBytes} → ${result.currentSizeInBytes} bytes)`,
143
- },
144
- ],
25
+ /**
26
+ * What a confirmation prompt confirmed, stored in `ctx.state` under
27
+ * `consent/<id>` when the handler asks; only the id travels as `requestState`.
28
+ */
29
+ const ConsentRecord = z.object({
30
+ operation: z.string().describe('Tool the record was minted for.'),
31
+ clientId: z.string().describe('Authenticated client that was asked; empty without auth.'),
32
+ subject: z.string().describe('Authenticated subject that was asked; empty without auth.'),
33
+ target: z.string().describe('Resolved vault path the user confirmed.'),
34
+ contentHash: z.string().describe('SHA-256 of the note content the user confirmed.'),
145
35
  });
36
+ /**
37
+ * Read the note at exactly `path`, letter case included, before anything can
38
+ * be deleted. The plugin's raw-markdown read and its v4.x DELETE
39
+ * (`adapter.exists` + `adapter.remove`) case-fold on a case-insensitive
40
+ * filesystem (macOS, Windows), so a wrong-case path would read — and then
41
+ * permanently remove — the differently-cased note. The `note+json` read
42
+ * resolves through Obsidian's case-sensitive vault index on v4.x and v5.x, so
43
+ * a wrong-case path 404s here. A miss names the case-insensitive and
44
+ * extension-stripped near matches in its folder; a delete never substitutes
45
+ * one. A folder path still fails `path_is_directory`: the folder listing has
46
+ * no `Content-Disposition` on this route either.
47
+ *
48
+ * The bytes come from a second, raw-markdown read of the path that read
49
+ * confirmed. `note+json` serves `cachedRead`, which lags a change made outside
50
+ * Obsidian (sync, git, another editor) until its file watcher fires, so a
51
+ * hash of it could match a consent record for text the file no longer holds.
52
+ */
53
+ async function readExactNote(ctx, svc, path) {
54
+ const target = { type: 'path', path };
55
+ try {
56
+ await svc.getNoteJson(ctx, target);
57
+ }
58
+ catch (err) {
59
+ if (!(err instanceof McpError) || err.code !== JsonRpcErrorCode.NotFound)
60
+ throw err;
61
+ const suggestions = await findSimilarPaths(ctx, svc, path);
62
+ if (suggestions.length === 0)
63
+ throw err;
64
+ const list = suggestions.map((s) => `"${s}"`).join(', ');
65
+ throw notFound(`${err.message.replace(/[.!?]?\s*$/, '')}. Did you mean: ${list}?`, { ...(err.data ?? {}), suggestions }, { cause: err });
66
+ }
67
+ return await svc.getNoteContent(ctx, target);
68
+ }
69
+ /**
70
+ * `elicitation` mirrors `OBSIDIAN_DELETE_ELICITATION`. Off, the operator's
71
+ * `OBSIDIAN_WRITE_PATHS` / `OBSIDIAN_READ_ONLY` are what bound a delete — the
72
+ * same bound `obsidian_write_note` with `overwrite: true` already has.
73
+ */
74
+ export function buildDeleteNoteTool({ elicitation }) {
75
+ return tool('obsidian_delete_note', {
76
+ description: elicitation
77
+ ? 'Permanently delete a note from the vault. Asks the user to confirm before deleting — the call is answered with a confirmation request and retried with the answer. Recovery requires the local trash in Obsidian — there is no API-level undo.'
78
+ : 'Permanently delete a note from the vault. Deletes on the first call without asking the user to confirm. Recovery requires the local trash in Obsidian — there is no API-level undo.',
79
+ annotations: { destructiveHint: true },
80
+ input: z.object({
81
+ target: TargetSchema.describe('Which note to delete.'),
82
+ }),
83
+ output: z.object({
84
+ path: z.string().describe('Resolved vault-relative path of the deleted note.'),
85
+ deleted: z.boolean().describe('True when the file was removed.'),
86
+ previousSizeInBytes: z
87
+ .number()
88
+ .describe('Byte size of the note immediately before deletion. Confirms the size of what was removed.'),
89
+ currentSizeInBytes: z
90
+ .number()
91
+ .describe('Always 0 after a successful delete — the file no longer exists.'),
92
+ }),
93
+ auth: ['tool:obsidian_delete_note:write'],
94
+ errors: [
95
+ {
96
+ reason: 'path_forbidden',
97
+ thrownBy: 'service',
98
+ code: JsonRpcErrorCode.Forbidden,
99
+ when: 'The target path is outside OBSIDIAN_WRITE_PATHS, or OBSIDIAN_READ_ONLY=true denies all writes.',
100
+ recovery: 'Use a path inside the configured write scope. The error data echoes the active scope.',
101
+ },
102
+ /** Declared only where it can fire: the off build never asks, so never throws it. */
103
+ ...(elicitation
104
+ ? [
105
+ {
106
+ reason: 'cancelled',
107
+ code: JsonRpcErrorCode.InvalidRequest,
108
+ when: 'User declined, cancelled, or answered false to the confirmation request.',
109
+ severity: 'notice',
110
+ recovery: 'Re-run the tool when the user is ready to confirm deletion.',
111
+ },
112
+ ]
113
+ : []),
114
+ {
115
+ reason: 'note_missing',
116
+ thrownBy: 'service',
117
+ code: JsonRpcErrorCode.NotFound,
118
+ when: 'No note has exactly this vault path, letter case included. Near matches in the same folder, such as a different-case spelling, are listed in `suggestions` and never deleted in its place.',
119
+ recovery: 'Verify the path with obsidian_list_notes or use obsidian_search_notes to locate the note. Deletes match the path exactly, letter case included; pass a path from `suggestions` as given.',
120
+ },
121
+ {
122
+ reason: 'no_active_file',
123
+ thrownBy: 'service',
124
+ code: JsonRpcErrorCode.NotFound,
125
+ when: 'Target was `active` but no file is currently open in Obsidian.',
126
+ recovery: 'Call obsidian_open_in_ui to focus a file, or pass an explicit path target instead.',
127
+ },
128
+ {
129
+ reason: 'periodic_unsupported',
130
+ thrownBy: 'service',
131
+ code: JsonRpcErrorCode.NotFound,
132
+ when: 'Target was `periodic` and this vault runs Local REST API v5.0.2 or later without the companion periodic-notes extension, so the `/periodic/` routes are not served at all.',
133
+ recovery: 'Install the periodic-notes extension from https://github.com/coddingtonbear/obsidian-local-rest-api-periodic-notes, or address the note by an explicit vault path.',
134
+ },
135
+ {
136
+ reason: 'periodic_not_found',
137
+ thrownBy: 'service',
138
+ code: JsonRpcErrorCode.NotFound,
139
+ when: 'Target was `periodic`, the `/periodic/` routes are served on this vault, and no note exists for the requested period.',
140
+ recovery: 'Pass an explicit path target — periodic notes must already exist.',
141
+ },
142
+ {
143
+ reason: 'periodic_disabled',
144
+ thrownBy: 'service',
145
+ code: JsonRpcErrorCode.ValidationError,
146
+ when: "Target was `periodic` but the requested period is not enabled in Obsidian's Periodic Notes plugin settings.",
147
+ recovery: "Pass an explicit path target — the requested period is disabled in the operator's Periodic Notes plugin.",
148
+ },
149
+ {
150
+ reason: 'path_is_directory',
151
+ thrownBy: 'service',
152
+ code: JsonRpcErrorCode.ValidationError,
153
+ when: 'The supplied path names a folder rather than a note file. Deleting a folder is not offered — the upstream removes it and everything inside it in one unrecoverable step.',
154
+ recovery: 'Call obsidian_list_notes with this path to list the folder, then delete files one at a time by their full paths.',
155
+ },
156
+ {
157
+ reason: 'path_traversal',
158
+ thrownBy: 'service',
159
+ code: JsonRpcErrorCode.ValidationError,
160
+ when: 'The path contains a `.` or `..` segment, which is rejected to prevent vault escape.',
161
+ recovery: 'Supply a vault-relative path with no `.` or `..` segments, e.g. "Projects/Note.md". Use obsidian_list_notes to browse the vault.',
162
+ },
163
+ ],
164
+ async handler(input, ctx) {
165
+ /**
166
+ * Redeem first: whatever this round carries, the record it names is spent
167
+ * before anything acts on the answer. An accepted answer on `ctx.inputs` is
168
+ * not proof the user was asked — a client that declared elicitation can
169
+ * pre-answer a call nothing prompted for — so only the record this handler
170
+ * stored when it asked makes the answer count.
171
+ *
172
+ * Single-use holds against a sequential replay, not concurrent retries:
173
+ * `ctx.state` has no atomic read-and-delete, so retries carrying one id at
174
+ * the same moment can each read the record before a delete lands, and the
175
+ * Local REST API DELETE takes no idempotency key to dedupe them upstream.
176
+ * The losing DELETE finds the file gone and fails `note_missing`, so the
177
+ * race removes nothing beyond the confirmed note unless that path is
178
+ * recreated in between. Tracked as cyanheads/mcp-ts-core#593 (an atomic
179
+ * `ctx.state.take`).
180
+ *
181
+ * With the confirmation off, nothing on `ctx.inputs` is read and nothing
182
+ * is written to `ctx.state`.
183
+ */
184
+ const id = elicitation ? ctx.inputs.state() : undefined;
185
+ const record = id && UUID.test(id) ? await ctx.state.get(`consent/${id}`, ConsentRecord) : null;
186
+ if (record)
187
+ await ctx.state.delete(`consent/${id}`);
188
+ /**
189
+ * Write scope is checked on the resolved path before the note is read, so
190
+ * a note the caller can read but not delete fails `path_forbidden` without
191
+ * a prompt or a stored record.
192
+ */
193
+ const svc = getObsidianService();
194
+ const path = await svc.resolvePath(ctx, input.target);
195
+ svc.policy.assertWritable(path);
196
+ const pathTarget = { type: 'path', path };
197
+ /**
198
+ * The read runs in both modes: it throws `path_is_directory` for a folder,
199
+ * which the DELETE would remove with everything inside it, and
200
+ * `note_missing` when no note has exactly this path — already gone, or a
201
+ * case variant of one (see `readExactNote`). With the confirmation on, it
202
+ * also serves the prompt and the record: the bytes the user is told
203
+ * they're destroying, and the hash a later round must still match.
204
+ */
205
+ const content = await readExactNote(ctx, svc, path);
206
+ const previousSizeInBytes = Buffer.byteLength(content);
207
+ if (elicitation) {
208
+ const expected = {
209
+ operation: OPERATION,
210
+ clientId: ctx.auth?.clientId ?? '',
211
+ subject: ctx.auth?.sub ?? '',
212
+ target: path,
213
+ contentHash: createHash('sha256').update(content).digest('hex'),
214
+ };
215
+ const matches = record !== null && isDeepStrictEqual(record, expected);
216
+ /**
217
+ * A declined or cancelled prompt the user was actually shown is a dead
218
+ * end, not a round to retry — re-asking would burn the round budget
219
+ * until the client gives up.
220
+ */
221
+ const view = ctx.inputs.view('confirm');
222
+ if (matches && view.kind === 'elicit' && view.action !== 'accept') {
223
+ throw ctx.fail('cancelled', `User sent '${view.action}' for the deletion confirmation.`, {
224
+ path,
225
+ });
226
+ }
227
+ const answer = matches ? ctx.inputs.accepted('confirm', DeleteConfirmation) : undefined;
228
+ if (!answer) {
229
+ const fresh = randomUUID();
230
+ await ctx.state.set(`consent/${fresh}`, expected, { ttl: CONSENT_TTL_SECONDS });
231
+ return ctx.requestInput({
232
+ inputRequests: {
233
+ confirm: inputRequired.elicit({
234
+ message: `Permanently delete '${path}' (${previousSizeInBytes} bytes)? This cannot be undone via the API; recovery would require Obsidian's local trash.`,
235
+ requestedSchema: DeleteConfirmation,
236
+ }),
237
+ },
238
+ requestState: fresh,
239
+ });
240
+ }
241
+ if (!answer.confirm) {
242
+ throw ctx.fail('cancelled', 'Deletion cancelled by user.', { path });
243
+ }
244
+ }
245
+ await svc.deleteNote(ctx, pathTarget);
246
+ return { path, deleted: true, previousSizeInBytes, currentSizeInBytes: 0 };
247
+ },
248
+ format: (result) => [
249
+ {
250
+ type: 'text',
251
+ text: `**Deleted ${result.path}** (size: ${result.previousSizeInBytes} → ${result.currentSizeInBytes} bytes)`,
252
+ },
253
+ ],
254
+ });
255
+ }
256
+ /**
257
+ * Static specimen for the MCP definition linter (which duck-types tool
258
+ * exports out of each `.tool.ts` file) and for tests that import the tool
259
+ * directly. Built with the confirmation off — the default, and what an
260
+ * unconfigured install registers. The entry point (`src/index.ts`) builds the
261
+ * live tool via `buildDeleteNoteTool` from `OBSIDIAN_DELETE_ELICITATION`; this
262
+ * export is not the registered tool. The confirming variant is exercised by
263
+ * tests rather than the linter (two exports under the same tool name would
264
+ * collide on `name-unique`).
265
+ */
266
+ export const obsidianDeleteNote = buildDeleteNoteTool({ elicitation: false });
146
267
  //# sourceMappingURL=obsidian-delete-note.tool.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"obsidian-delete-note.tool.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/obsidian-delete-note.tool.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"obsidian-delete-note.tool.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/obsidian-delete-note.tool.ts"],"names":[],"mappings":"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"}
@@ -147,6 +147,7 @@ export declare const obsidianGetNote: import("@cyanheads/mcp-ts-core").ToolDefin
147
147
  readonly when: "The path contains a `.` or `..` segment, which is rejected to prevent vault escape.";
148
148
  readonly recovery: "Supply a vault-relative path with no `.` or `..` segments, e.g. \"Projects/Note.md\". Use obsidian_list_notes to browse the vault.";
149
149
  }], {
150
+ readonly requestedPath: z.ZodOptional<z.ZodString>;
150
151
  readonly notice: z.ZodOptional<z.ZodString>;
151
152
  }>;
152
153
  //# sourceMappingURL=obsidian-get-note.tool.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"obsidian-get-note.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/obsidian-get-note.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAY,MAAM,+BAA+B,CAAC;AAgB3E,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsX1B,CAAC"}
1
+ {"version":3,"file":"obsidian-get-note.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/obsidian-get-note.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAY,MAAM,+BAA+B,CAAC;AAgB3E,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiY1B,CAAC"}
@@ -8,14 +8,14 @@ import { JsonRpcErrorCode, McpError } from '@cyanheads/mcp-ts-core/errors';
8
8
  import { getObsidianService } from '../../../services/obsidian/obsidian-service.js';
9
9
  import { computeFenceMask, extractSection, } from '../../../services/obsidian/section-extractor.js';
10
10
  import { SectionSchema, TargetSchema } from './_shared/schemas.js';
11
- import { withCaseFallback } from './_shared/suggest-paths.js';
11
+ import { discloseCaseSubstitution, withCaseFallback } from './_shared/suggest-paths.js';
12
12
  const StatSchema = z.object({
13
13
  ctime: z.number().describe('Created time, ms since epoch.'),
14
14
  mtime: z.number().describe('Modified time, ms since epoch.'),
15
15
  size: z.number().describe('File size in bytes.'),
16
16
  });
17
17
  export const obsidianGetNote = tool('obsidian_get_note', {
18
- description: 'Read a note from the vault — by path, the active file, or a periodic note. Choose a `format` projection: raw body, full object, structural document map, or a single section.',
18
+ description: 'Read a note from the vault — by path, the active file, or a periodic note. Choose a `format` projection: raw body, full object, structural document map, or a single section. A `path` with no exact match but a single case-insensitive match in its folder reads that file instead and reports the path as sent in `requestedPath`; write and delete tools match paths exactly.',
19
19
  annotations: { readOnlyHint: true, idempotentHint: true },
20
20
  input: z.object({
21
21
  format: z
@@ -53,7 +53,7 @@ export const obsidianGetNote = tool('obsidian_get_note', {
53
53
  .object({
54
54
  target: z
55
55
  .string()
56
- .describe('Link target as written — vault path, basename, or alias. No existence check.'),
56
+ .describe('Link target — a vault path or basename. A wikilink gives it as Obsidian reads it, without its `#heading` or `|alias` (`\\|alias` in a table); a markdown link gives its URL as written. No existence check.'),
57
57
  type: z.enum(['wikilink', 'markdown']).describe('Source syntax.'),
58
58
  })
59
59
  .describe('A single outgoing link reference.'))
@@ -97,10 +97,14 @@ export const obsidianGetNote = tool('obsidian_get_note', {
97
97
  .describe('Mode-discriminated projection of the requested note.'),
98
98
  }),
99
99
  enrichment: {
100
+ requestedPath: z
101
+ .string()
102
+ .optional()
103
+ .describe('The `target.path` as sent. Present only when it had no exact match and its single case-insensitive match in the same folder, `result.path`, was read instead — write and delete tools need `result.path`.'),
100
104
  notice: z
101
105
  .string()
102
106
  .optional()
103
- .describe('Guidance when a heading locator matched several headings — names the path that was read and points at `candidates` for the rest.'),
107
+ .describe('Guidance when the read substituted a case-insensitive path match (names both paths), when a heading locator matched several headings (names the path that was read and points at `candidates` for the rest), or both.'),
104
108
  },
105
109
  auth: ['tool:obsidian_get_note:read'],
106
110
  errors: [
@@ -185,16 +189,23 @@ export const obsidianGetNote = tool('obsidian_get_note', {
185
189
  const { target } = input;
186
190
  if (input.format === 'content') {
187
191
  if (target.type === 'path') {
188
- const { result: content, resolvedPath } = await withCaseFallback(ctx, svc, target, (t) => svc.getNoteContent(ctx, t));
192
+ const fallback = await withCaseFallback(ctx, svc, target, (t) => svc.getNoteContent(ctx, t));
193
+ discloseCaseSubstitution(ctx, fallback, 'read');
189
194
  return {
190
- result: { format: 'content', path: resolvedPath ?? target.path, content },
195
+ result: {
196
+ format: 'content',
197
+ path: fallback.resolvedPath ?? target.path,
198
+ content: fallback.result,
199
+ },
191
200
  };
192
201
  }
193
202
  const note = await svc.getNoteJson(ctx, target);
194
203
  return { result: { format: 'content', path: note.path, content: note.content } };
195
204
  }
196
205
  if (input.format === 'full') {
197
- const { result: note } = await withCaseFallback(ctx, svc, target, (t) => svc.getNoteJson(ctx, t));
206
+ const fallback = await withCaseFallback(ctx, svc, target, (t) => svc.getNoteJson(ctx, t));
207
+ discloseCaseSubstitution(ctx, fallback, 'read');
208
+ const note = fallback.result;
198
209
  return {
199
210
  result: {
200
211
  format: 'full',
@@ -209,11 +220,13 @@ export const obsidianGetNote = tool('obsidian_get_note', {
209
220
  }
210
221
  if (input.format === 'document-map') {
211
222
  if (target.type === 'path') {
212
- const { result: map, resolvedPath } = await withCaseFallback(ctx, svc, target, (t) => svc.getDocumentMap(ctx, t));
223
+ const fallback = await withCaseFallback(ctx, svc, target, (t) => svc.getDocumentMap(ctx, t));
224
+ discloseCaseSubstitution(ctx, fallback, 'read');
225
+ const map = fallback.result;
213
226
  return {
214
227
  result: {
215
228
  format: 'document-map',
216
- path: resolvedPath ?? target.path,
229
+ path: fallback.resolvedPath ?? target.path,
217
230
  headings: map.headings,
218
231
  blocks: map.blocks,
219
232
  frontmatterFields: map.frontmatterFields,
@@ -237,10 +250,11 @@ export const obsidianGetNote = tool('obsidian_get_note', {
237
250
  if (!input.section) {
238
251
  throw ctx.fail('section_required', '`section` is required when `format` is "section".', {
239
252
  format: input.format,
240
- ...ctx.recoveryFor('section_required'),
241
253
  });
242
254
  }
243
- const { result: note } = await withCaseFallback(ctx, svc, target, (t) => svc.getNoteJson(ctx, t));
255
+ const fallback = await withCaseFallback(ctx, svc, target, (t) => svc.getNoteJson(ctx, t));
256
+ const caseNotice = discloseCaseSubstitution(ctx, fallback, 'read');
257
+ const note = fallback.result;
244
258
  // `extractSection` throws `NotFound` for missing heading/block/frontmatter
245
259
  // targets — those route through the contract; anything else bubbles up to
246
260
  // the framework's default classifier.
@@ -251,12 +265,13 @@ export const obsidianGetNote = tool('obsidian_get_note', {
251
265
  catch (err) {
252
266
  if (!(err instanceof McpError) || err.code !== JsonRpcErrorCode.NotFound)
253
267
  throw err;
254
- throw ctx.fail('section_missing', err.message, { path: note.path, section: input.section, ...ctx.recoveryFor('section_missing') }, { cause: err });
268
+ throw ctx.fail('section_missing', err.message, { path: note.path, section: input.section }, { cause: err });
255
269
  }
256
270
  const { candidates, sectionTarget, value } = extracted;
257
271
  if (candidates) {
258
272
  const repeatedPath = candidates.every((c) => c === sectionTarget);
259
- ctx.enrich.notice(`Heading \`${input.section.target}\` is ambiguous — ${candidates.length} headings share that name; read \`${sectionTarget}\`. See \`candidates\` for the rest.${repeatedPath ? ' Every one has the same full path, so the write tools reject it with `ambiguous_section`.' : ''}`);
273
+ const headingNotice = `Heading \`${input.section.target}\` is ambiguous — ${candidates.length} headings share that name; read \`${sectionTarget}\`. See \`candidates\` for the rest.${repeatedPath ? ' Every one has the same full path, so the write tools reject it with `ambiguous_section`.' : ''}`;
274
+ ctx.enrich.notice(caseNotice ? `${caseNotice} ${headingNotice}` : headingNotice);
260
275
  }
261
276
  return {
262
277
  result: {
@@ -344,6 +359,37 @@ function stringifyValue(v) {
344
359
  return v;
345
360
  return JSON.stringify(v, null, 2);
346
361
  }
362
+ /**
363
+ * Wikilink: `[[body]]` or `![[body]]`, matched as Obsidian's metadata cache
364
+ * reads it — from `[[` to the first `]]` on the same line. Single `[` and `]`
365
+ * belong to the body (`[[Note|see [1]]]` links `Note`), but a second `[[`
366
+ * starts the link over, so every match attempt stops at the next `[[`, `]]`,
367
+ * or newline and the scan stays linear in note length. {@link wikilinkTarget}
368
+ * takes the target out of the body.
369
+ */
370
+ const WIKILINK = /!?\[\[((?:(?!\[\[|\]\])[^\n])*)\]\]/g;
371
+ /**
372
+ * The target of a wikilink body: the text before the first `|` (alias) or `#`
373
+ * (section). A backslash right before the alias pipe escapes it — Obsidian
374
+ * reads `[[Note\|A]]` as `Note` (the only way to alias inside a table cell) —
375
+ * while any other backslash stays: `[[Note\#H]]` and `[[Note\\|A]]` give `Note\`.
376
+ */
377
+ function wikilinkTarget(body) {
378
+ const end = body.search(/[|#]/);
379
+ if (end < 0)
380
+ return body.trim();
381
+ const target = body.slice(0, end);
382
+ return (body[end] === '|' && target.endsWith('\\') ? target.slice(0, -1) : target).trim();
383
+ }
384
+ /**
385
+ * Markdown link: `[text](url)` or `[text](url "title")`. The URL is either
386
+ * `<bracketed text with spaces>` — the markdown spec's escape hatch for paths
387
+ * containing spaces — or a non-whitespace run whose parentheses come in
388
+ * balanced pairs (`Note(1).md`). The text may not contain `[` or `]`, and a
389
+ * bare URL ends at an unpaired `(`, so an unclosed `[` or `[a](` stops at the
390
+ * next bracket or parenthesis instead of scanning to the end of the note.
391
+ */
392
+ const MARKDOWN_LINK = /\[[^[\]]*\]\((<[^<>\n]+>|(?:[^()\s]|\([^()\s]*\))+)(?:\s+"[^"]*")?\)/g;
347
393
  /**
348
394
  * Extract outgoing link references from note content. Captures Obsidian
349
395
  * wikilinks (`[[target]]`, `![[target]]`, with optional `|alias` or
@@ -357,17 +403,12 @@ function stringifyValue(v) {
357
403
  function parseOutgoingLinks(content) {
358
404
  const cleaned = stripMarkdownCode(content);
359
405
  const links = [];
360
- for (const m of cleaned.matchAll(/!?\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]/g)) {
361
- const target = m[1]?.trim();
406
+ for (const m of cleaned.matchAll(WIKILINK)) {
407
+ const target = wikilinkTarget(m[1] ?? '');
362
408
  if (target)
363
409
  links.push({ target, type: 'wikilink' });
364
410
  }
365
- /**
366
- * URL group accepts either `<bracketed text with spaces>` or a non-whitespace
367
- * run. The bracketed form is the markdown spec's escape hatch for paths
368
- * containing spaces.
369
- */
370
- for (const m of cleaned.matchAll(/\[[^\]]*\]\((<[^<>\n]+>|[^)\s]+)(?:\s+"[^"]*")?\)/g)) {
411
+ for (const m of cleaned.matchAll(MARKDOWN_LINK)) {
371
412
  let target = m[1]?.trim();
372
413
  if (!target)
373
414
  continue;