diff --git a/scripts/tool-surface-capture.ts b/scripts/tool-surface-capture.ts index 98b98371f..b1c0ad10d 100644 --- a/scripts/tool-surface-capture.ts +++ b/scripts/tool-surface-capture.ts @@ -79,8 +79,8 @@ const comboFromFlippedAxes = (flippedAxes: readonly SurfaceAxis[]): SurfaceCombo * - disabled-tools: vault_patch_note is cross-referenced from other tools' * descriptions, so its combo verifies those references disappear when the * tool is disabled. - * - obsidian-sync: OBSIDIAN_SYNC changes only vault_delete_note's Errors - * list, so one combo pins it; crossing it with the axes would double the + * - obsidian-sync: OBSIDIAN_SYNC changes only vault_delete_note's text, + * so one combo pins it; crossing it with the axes would double the * baseline without adding a rendered state. */ export const SURFACE_COMBOS: readonly SurfaceCombo[] = [ ...axisSubsets.map(comboFromFlippedAxes), diff --git a/src/__tests__/integration/server-error-contracts.test.ts b/src/__tests__/integration/server-error-contracts.test.ts index 618eb34d6..1c3f7cda7 100644 --- a/src/__tests__/integration/server-error-contracts.test.ts +++ b/src/__tests__/integration/server-error-contracts.test.ts @@ -254,6 +254,30 @@ describe("absolute path blocked", () => { `absolute path blocked: "${serverVaultPath}/moved.md" must be vault-relative`, ) }) + + it("vault_list_notes rejects an absolute container path as folder", async () => { + const result = await callTool({ + client, + name: "vault_list_notes", + args: { folder: `${serverVaultPath}/Projects` }, + }) + expectToolError( + result, + `absolute path blocked: "${serverVaultPath}/Projects" must be vault-relative`, + ) + }) + + it("vault_list_files rejects an absolute container path as folder", async () => { + const result = await callTool({ + client, + name: "vault_list_files", + args: { folder: `${serverVaultPath}/Projects` }, + }) + expectToolError( + result, + `absolute path blocked: "${serverVaultPath}/Projects" must be vault-relative`, + ) + }) }) // ── Path traversal ─────────────────────────────────────────── @@ -370,6 +394,42 @@ describe("path traversal blocked", () => { }) expectToolError(result, "path traversal blocked") }) + + it("vault_list_notes rejects a folder escaping the vault root", async () => { + const result = await callTool({ + client, + name: "vault_list_notes", + args: { folder: "../outside" }, + }) + expectToolError(result, 'path traversal blocked: "../outside" escapes vault root') + }) + + it("vault_list_notes rejects a folder naming the vault root", async () => { + const result = await callTool({ + client, + name: "vault_list_notes", + args: { folder: "." }, + }) + expectToolError(result, 'path traversal blocked: "." resolves to the vault root') + }) + + it("vault_list_files rejects a folder escaping the vault root", async () => { + const result = await callTool({ + client, + name: "vault_list_files", + args: { folder: "../outside" }, + }) + expectToolError(result, 'path traversal blocked: "../outside" escapes vault root') + }) + + it("vault_list_files rejects a folder naming the vault root", async () => { + const result = await callTool({ + client, + name: "vault_list_files", + args: { folder: "." }, + }) + expectToolError(result, 'path traversal blocked: "." resolves to the vault root') + }) }) // ── Hidden paths ───────────────────────────────────────────── @@ -486,6 +546,24 @@ describe("hidden path blocked", () => { }) expectToolError(result, "hidden path blocked") }) + + it("vault_list_notes rejects a hidden folder", async () => { + const result = await callTool({ + client, + name: "vault_list_notes", + args: { folder: ".obsidian" }, + }) + expectToolError(result, 'hidden path blocked: ".obsidian" targets a hidden file or folder') + }) + + it("vault_list_files rejects a hidden folder", async () => { + const result = await callTool({ + client, + name: "vault_list_files", + args: { folder: ".obsidian" }, + }) + expectToolError(result, 'hidden path blocked: ".obsidian" targets a hidden file or folder') + }) }) // ── Note not found ─────────────────────────────────────────── diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json index db0408a5c..bad1f8190 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json @@ -195,7 +195,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- Unless the server syncs through Obsidian Sync, the \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. The server deletes its own copies there after its TRASH_RETENTION_DAYS setting (default 30 days, or never when set to none), never touching notes Obsidian trashed.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- When the server syncs through Obsidian Sync, the setting is bypassed and the note is always deleted for good; recover it from Sync's version history (1 month on Standard, 12 months on Plus).\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ already holds this name and its numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); clear old trash copies, then retry\n- any other \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed folder); the note stays put; fix .trash/, then retry\n- any other \"cannot delete …\" — the permanent delete failed (e.g. permissions); the note stays put; fix the cause, then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but is unreadable; the delete is blocked because a guessed setting could let the retention sweep remove a note set to be kept forever; repair the file, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash ()\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -272,12 +272,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -307,7 +307,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search, vault_list_notes, or vault_list_files.", "inputSchema": { "type": "object", "properties": { @@ -537,7 +537,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -1154,13 +1154,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -1467,19 +1467,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -1488,7 +1488,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1513,7 +1513,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json index 5d656c7ca..5ad2f6433 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json @@ -197,7 +197,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- Unless the server syncs through Obsidian Sync, the \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. The server deletes its own copies there after its TRASH_RETENTION_DAYS setting (default 30 days, or never when set to none), never touching notes Obsidian trashed.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- When the server syncs through Obsidian Sync, the setting is bypassed and the note is always deleted for good; recover it from Sync's version history (1 month on Standard, 12 months on Plus).\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ already holds this name and its numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); clear old trash copies, then retry\n- any other \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed folder); the note stays put; fix .trash/, then retry\n- any other \"cannot delete …\" — the permanent delete failed (e.g. permissions); the note stays put; fix the cause, then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but is unreadable; the delete is blocked because a guessed setting could let the retention sweep remove a note set to be kept forever; repair the file, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash ()\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -274,12 +274,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -309,7 +309,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search, vault_list_notes, or vault_list_files.", "inputSchema": { "type": "object", "properties": { @@ -539,7 +539,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -1096,13 +1096,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -1409,19 +1409,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -1430,7 +1430,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1455,7 +1455,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json index 8938268a3..f6d509580 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json @@ -197,7 +197,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- Unless the server syncs through Obsidian Sync, the \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. The server deletes its own copies there after its TRASH_RETENTION_DAYS setting (default 30 days, or never when set to none), never touching notes Obsidian trashed.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- When the server syncs through Obsidian Sync, the setting is bypassed and the note is always deleted for good; recover it from Sync's version history (1 month on Standard, 12 months on Plus).\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ already holds this name and its numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); clear old trash copies, then retry\n- any other \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed folder); the note stays put; fix .trash/, then retry\n- any other \"cannot delete …\" — the permanent delete failed (e.g. permissions); the note stays put; fix the cause, then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but is unreadable; the delete is blocked because a guessed setting could let the retention sweep remove a note set to be kept forever; repair the file, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash ()\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -274,12 +274,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -309,7 +309,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search, vault_list_notes, or vault_list_files.", "inputSchema": { "type": "object", "properties": { @@ -539,7 +539,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -1156,13 +1156,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -1469,19 +1469,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -1490,7 +1490,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1515,7 +1515,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json index 4c7d272d5..a6b478146 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json @@ -198,7 +198,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- Unless the server syncs through Obsidian Sync, the \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. The server deletes its own copies there after its TRASH_RETENTION_DAYS setting (default 30 days, or never when set to none), never touching notes Obsidian trashed.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- When the server syncs through Obsidian Sync, the setting is bypassed and the note is always deleted for good; recover it from Sync's version history (1 month on Standard, 12 months on Plus).\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ already holds this name and its numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); clear old trash copies, then retry\n- any other \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed folder); the note stays put; fix .trash/, then retry\n- any other \"cannot delete …\" — the permanent delete failed (e.g. permissions); the note stays put; fix the cause, then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but is unreadable; the delete is blocked because a guessed setting could let the retention sweep remove a note set to be kept forever; repair the file, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash ()\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -275,12 +275,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -310,7 +310,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search or vault_list_notes.", "inputSchema": { "type": "object", "properties": { @@ -499,7 +499,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -1072,13 +1072,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -1385,19 +1385,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -1406,7 +1406,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1431,7 +1431,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json index 023faf02b..3e51c04c7 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json @@ -197,7 +197,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- Unless the server syncs through Obsidian Sync, the \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. The server deletes its own copies there after its TRASH_RETENTION_DAYS setting (default 30 days, or never when set to none), never touching notes Obsidian trashed.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- When the server syncs through Obsidian Sync, the setting is bypassed and the note is always deleted for good; recover it from Sync's version history (1 month on Standard, 12 months on Plus).\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ already holds this name and its numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); clear old trash copies, then retry\n- any other \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed folder); the note stays put; fix .trash/, then retry\n- any other \"cannot delete …\" — the permanent delete failed (e.g. permissions); the note stays put; fix the cause, then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but is unreadable; the delete is blocked because a guessed setting could let the retention sweep remove a note set to be kept forever; repair the file, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash ()\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -274,12 +274,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -309,7 +309,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search or vault_list_notes.", "inputSchema": { "type": "object", "properties": { @@ -498,7 +498,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -1071,13 +1071,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -1384,19 +1384,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -1405,7 +1405,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1430,7 +1430,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json index 2f52a18ed..290d112cd 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json @@ -152,7 +152,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- Unless the server syncs through Obsidian Sync, the \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. The server deletes its own copies there after its TRASH_RETENTION_DAYS setting (default 30 days, or never when set to none), never touching notes Obsidian trashed.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- When the server syncs through Obsidian Sync, the setting is bypassed and the note is always deleted for good; recover it from Sync's version history (1 month on Standard, 12 months on Plus).\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ already holds this name and its numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); clear old trash copies, then retry\n- any other \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed folder); the note stays put; fix .trash/, then retry\n- any other \"cannot delete …\" — the permanent delete failed (e.g. permissions); the note stays put; fix the cause, then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but is unreadable; the delete is blocked because a guessed setting could let the retention sweep remove a note set to be kept forever; repair the file, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash ()\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -229,12 +229,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -264,7 +264,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search, vault_list_notes, or vault_list_files.", "inputSchema": { "type": "object", "properties": { @@ -440,7 +440,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -1017,13 +1017,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -1330,19 +1330,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -1351,7 +1351,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1376,7 +1376,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json index 0b0e16d5e..f88160835 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json @@ -153,7 +153,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- Unless the server syncs through Obsidian Sync, the \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. The server deletes its own copies there after its TRASH_RETENTION_DAYS setting (default 30 days, or never when set to none), never touching notes Obsidian trashed.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- When the server syncs through Obsidian Sync, the setting is bypassed and the note is always deleted for good; recover it from Sync's version history (1 month on Standard, 12 months on Plus).\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ already holds this name and its numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); clear old trash copies, then retry\n- any other \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed folder); the note stays put; fix .trash/, then retry\n- any other \"cannot delete …\" — the permanent delete failed (e.g. permissions); the note stays put; fix the cause, then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but is unreadable; the delete is blocked because a guessed setting could let the retention sweep remove a note set to be kept forever; repair the file, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash ()\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -230,12 +230,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -265,7 +265,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search or vault_list_notes.", "inputSchema": { "type": "object", "properties": { @@ -400,7 +400,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -933,13 +933,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -1246,19 +1246,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -1267,7 +1267,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1292,7 +1292,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json index 31e061855..725bdb672 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json @@ -152,7 +152,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- Unless the server syncs through Obsidian Sync, the \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. The server deletes its own copies there after its TRASH_RETENTION_DAYS setting (default 30 days, or never when set to none), never touching notes Obsidian trashed.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- When the server syncs through Obsidian Sync, the setting is bypassed and the note is always deleted for good; recover it from Sync's version history (1 month on Standard, 12 months on Plus).\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ already holds this name and its numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); clear old trash copies, then retry\n- any other \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed folder); the note stays put; fix .trash/, then retry\n- any other \"cannot delete …\" — the permanent delete failed (e.g. permissions); the note stays put; fix the cause, then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but is unreadable; the delete is blocked because a guessed setting could let the retention sweep remove a note set to be kept forever; repair the file, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash ()\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -229,12 +229,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -264,7 +264,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search or vault_list_notes.", "inputSchema": { "type": "object", "properties": { @@ -399,7 +399,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -932,13 +932,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -1245,19 +1245,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -1266,7 +1266,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1291,7 +1291,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json index ba41b704a..35db87b37 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json @@ -151,7 +151,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- Unless the server syncs through Obsidian Sync, the \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. The server deletes its own copies there after its TRASH_RETENTION_DAYS setting (default 30 days, or never when set to none), never touching notes Obsidian trashed.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- When the server syncs through Obsidian Sync, the setting is bypassed and the note is always deleted for good; recover it from Sync's version history (1 month on Standard, 12 months on Plus).\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ already holds this name and its numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); clear old trash copies, then retry\n- any other \"cannot move to trash …\" — the .trash/ move failed (e.g. a plain file blocks a needed folder); the note stays put; fix .trash/, then retry\n- any other \"cannot delete …\" — the permanent delete failed (e.g. permissions); the note stays put; fix the cause, then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but is unreadable; the delete is blocked because a guessed setting could let the retention sweep remove a note set to be kept forever; repair the file, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash ()\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -228,12 +228,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -263,7 +263,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search, vault_list_notes, or vault_list_files.", "inputSchema": { "type": "object", "properties": { @@ -439,7 +439,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -1016,13 +1016,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -1329,19 +1329,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -1350,7 +1350,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1375,7 +1375,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json index f60cd0d1c..29c99182b 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json @@ -197,7 +197,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- Unless the server syncs through Obsidian Sync, the \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. The server deletes its own copies there after its TRASH_RETENTION_DAYS setting (default 30 days, or never when set to none), never touching notes Obsidian trashed.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- When the server syncs through Obsidian Sync, the setting is bypassed and the note is always deleted for good; recover it from Sync's version history (1 month on Standard, 12 months on Plus).\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken (detectable via vault_get_backlinks). Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) are refused.\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — the note does not exist; verify the path with vault_list_notes before deleting\n- any other \"cannot delete …\" — the permanent delete failed (e.g. permissions); the note stays put; fix the cause, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash ()\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note for good: this server syncs through Obsidian Sync, so it bypasses the vault's \"Deleted files\" setting; recover a deleted note from Sync's version history (1 month on Standard, 12 months on Plus).\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message — \"Deleted \". Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -274,12 +274,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -309,7 +309,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search, vault_list_notes, or vault_list_files.", "inputSchema": { "type": "object", "properties": { @@ -539,7 +539,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -1156,13 +1156,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -1469,19 +1469,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -1490,7 +1490,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1515,7 +1515,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json index 50191f640..dc945359a 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json @@ -9,12 +9,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -44,7 +44,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search, vault_list_notes, or vault_list_files.", "inputSchema": { "type": "object", "properties": { @@ -220,7 +220,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -738,13 +738,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -957,19 +957,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -978,7 +978,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1003,7 +1003,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json index 32beefa85..f33df84b8 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json @@ -10,12 +10,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -45,7 +45,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search or vault_list_notes.", "inputSchema": { "type": "object", "properties": { @@ -180,7 +180,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -654,13 +654,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -873,19 +873,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -894,7 +894,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -919,7 +919,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json index 7e6463f9e..391f0951d 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json @@ -9,12 +9,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -44,7 +44,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search or vault_list_notes.", "inputSchema": { "type": "object", "properties": { @@ -179,7 +179,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -653,13 +653,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -872,19 +872,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -893,7 +893,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -918,7 +918,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+embedding-off.json index d35035218..e8a92f4b6 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+embedding-off.json @@ -10,12 +10,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -45,7 +45,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search, vault_list_notes, or vault_list_files.", "inputSchema": { "type": "object", "properties": { @@ -167,7 +167,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -645,13 +645,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -864,19 +864,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -885,7 +885,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -910,7 +910,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off+embedding-off.json index 034bf1805..d5181cac7 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off+embedding-off.json @@ -11,12 +11,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -46,7 +46,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search or vault_list_notes.", "inputSchema": { "type": "object", "properties": { @@ -127,7 +127,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -561,13 +561,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -780,19 +780,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -801,7 +801,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -826,7 +826,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off.json index f3d563a18..057e8a6a4 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off.json @@ -10,12 +10,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -45,7 +45,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search or vault_list_notes.", "inputSchema": { "type": "object", "properties": { @@ -126,7 +126,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -560,13 +560,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -779,19 +779,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -800,7 +800,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -825,7 +825,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off.json index c02895113..f3a685277 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off.json @@ -9,12 +9,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -44,7 +44,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search, vault_list_notes, or vault_list_files.", "inputSchema": { "type": "object", "properties": { @@ -166,7 +166,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -644,13 +644,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -863,19 +863,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -884,7 +884,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -909,7 +909,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json index 8ef0045ef..31b90d7ed 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json @@ -8,12 +8,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", + "description": "Folder paths to exclude (e.g. Projects; default: the daily notes folder, \"Templates\", \"About Me\")", "type": "array", "items": { "type": "string", @@ -43,7 +43,7 @@ { "name": "vault_get_backlinks", "title": "Get Backlinks", - "description": "Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.", + "description": "Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks.\n\nExample: vault_get_backlinks({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_backlinks({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph.\nFor outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive.\n\nReturns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path nothing links to returns an empty result (count 0), not an error — if you expected links, find valid paths with vault_search, vault_list_notes, or vault_list_files.", "inputSchema": { "type": "object", "properties": { @@ -219,7 +219,7 @@ { "name": "vault_list_notes", "title": "List Notes", - "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", + "description": "List .md file paths in the vault, optionally filtered by folder and/or glob pattern. Returns paths only — not content or metadata.\n\nExample: vault_list_notes({ folder: \"Projects\" })\nExample: vault_list_notes({ glob: \"**/*session-log*.md\" })\nExample: vault_list_notes({ folder: \"Projects\", glob: \"*.md\" }) — the folder's top-level notes only\n\nWhen to use: Browsing what exists in a folder by filename, or finding notes matching a path pattern.\nPrefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results.\n\nParameters:\n- folder names a whole folder; without a glob, notes in its subfolders are listed too: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing.\n- glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder \"Projects\", \"*.md\" lists the folder's top-level notes and \"**/*.md\" every note under it. Returned paths are always vault-relative.\n\nBehavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included.\n\nErrors:\n- A nonexistent folder or no glob matches returns an empty array, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault.\n\nReturns: JSON array of vault-relative path strings (e.g. [\"Notes/idea.md\", \"Projects/plan.md\"]).", "inputSchema": { "type": "object", "properties": { @@ -737,13 +737,13 @@ { "name": "vault_recent_notes", "title": "Recent Notes", - "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, so every note has a value and limit works predictably. \"created\" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use \"modified\" for broader coverage.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.", + "description": "List recently modified or created notes, sorted by timestamp — a time-ordered window into the vault, not a date-range filter.\n\nExample: vault_recent_notes({ sort_by: \"modified\", limit: 10 })\nExample: vault_recent_notes({ sort_by: \"created\", limit: 5 })\n\nWhen to use: Catching up on vault changes, finding recent work, or orienting after a break.\nPrefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder.\n\nParameters:\n- sort_by + limit interact: \"modified\" (default) uses filesystem mtime, which every note has. \"created\" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use \"modified\" to see them.\n- \"modified\" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits.\n\nErrors:\n- An empty vault returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.", "inputSchema": { "type": "object", "properties": { "sort_by": { "default": "modified", - "description": "Sort order (default \"modified\")", + "description": "Timestamp to sort by, newest first (default \"modified\")", "type": "string", "enum": [ "created", @@ -956,19 +956,19 @@ { "name": "vault_search_by_property", "title": "Search by Property", - "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match).\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\".\n- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like \"4abc\", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text.\n- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as \"1e-999\" matches stored zero.\n- Pass a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0); \"1.0\" does not match a checked checkbox.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", + "description": "Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: \"active\") and array properties (tags, related): for arrays, matches if any element equals the value.\n\nExample: vault_search_by_property({ key: \"status\", value: \"in-progress\" })\nExample: vault_search_by_property({ key: \"type\", value: \"session-log\", folder: \"Code Projects\" })\n\nWhen to use: Finding notes by metadata when you don't have a text query.\nPrefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes.\n\nParameters:\n- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing.\n- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: \"04\" and \"4.0\" match number 4 and their own literal text, but not text \"4\". Anything else (\"4abc\", \" 4\", 0b binary, \"1e999\") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and \"1e-999\" matches 0.\n- Pass a checkbox as \"1\" (checked) or \"0\" (unchecked); a checkbox matches only as text, so \"1.0\" and \"true\" do not match.\n- An array element must equal value in full: \"blog\" matches tags: [\"blog\", \"draft\"] but not tags: [\"my-blog\"].\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case; omit folder to search the entire vault.\n- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An unknown key or unmatched value returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits.\n- leading_callout appears only when the note has a leading callout.\n- additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.", "inputSchema": { "type": "object", "properties": { "key": { "type": "string", "minLength": 1, - "description": "Property key name (e.g. \"status\", \"type\", \"tags\"). Use vault_list_property_keys to discover valid keys." + "description": "Property key name (e.g. \"status\", \"type\", \"tags\")." }, "value": { "type": "string", "minLength": 1, - "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\"). Use vault_list_property_values to discover valid values for a key." + "description": "Value to match (e.g. \"active\", \"4\", \"1e-7\")." }, "folder": { "description": "Restrict to a folder (e.g. \"Projects\")", @@ -977,7 +977,7 @@ }, "limit": { "default": 20, - "description": "Max results (default 20)", + "description": "Max results (default 20, no upper cap)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1002,7 +1002,7 @@ { "name": "vault_search_by_tag", "title": "Search by Tag", - "description": "Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- tag + exact interact: the prefix match follows the \"/\" separator, so \"project\" matches itself and its children but does NOT match \"my-project\" or \"projects\". exact=true matches only the literal tag, excluding children.\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.", + "description": "Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. \"project\" matches \"project/vault-cortex\", \"project/blog\"). Set exact=true for exact match only.\n\nExample: vault_search_by_tag({ tag: \"project\" })\n\nWhen to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query.\nPrefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags.\n\nParameters:\n- Prefix mode follows the \"/\" separator: \"project\" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match \"my-project\" or \"projects\".\n\nErrors:\n- An unknown tag or no matches returns an empty array, not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts index 00943f8de..a7a0127b3 100644 --- a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts @@ -146,6 +146,19 @@ const extractDescriptionSection = (params: { return description.slice(sectionStart, sectionEnd) } +/** The first line of a tool's description that starts with linePrefix, or + * undefined when the tool is not registered or no line matches. */ +const findDescriptionLine = (params: { + registeredCalls: readonly RegisterToolCall[] + toolName: string + linePrefix: string +}): string | undefined => { + const toolCall = params.registeredCalls.find(([toolName]) => toolName === params.toolName) + const descriptionLines = toolCall?.[1].description?.split("\n") + + return descriptionLines?.find((line) => line.startsWith(params.linePrefix)) +} + describe("registerTools", () => { it(`registers exactly ${ALL_TOOL_NAMES.length} tools`, () => { expect(mockServer.registerTool).toHaveBeenCalledTimes(ALL_TOOL_NAMES.length) @@ -327,9 +340,14 @@ describe("registerTools", () => { }) it("vault_recent_notes description documents sorting behavior", () => { - const [, config] = requireCall(TOOL_NAMES.VAULT_RECENT_NOTES) - expect(config.description).toContain("filesystem mtime") - expect(config.description).toContain("sort last") + const sortLine = findDescriptionLine({ + registeredCalls: [requireCall(TOOL_NAMES.VAULT_RECENT_NOTES)], + toolName: TOOL_NAMES.VAULT_RECENT_NOTES, + linePrefix: "- sort_by + limit interact", + }) + expect(sortLine).toBe( + '- sort_by + limit interact: "modified" (default) uses filesystem mtime, which every note has. "created" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use "modified" to see them.', + ) }) it("vault_read_note description cross-references graph tools", () => { @@ -492,10 +510,26 @@ describe("config interpolation in descriptions", () => { }, ) - it("vault_delete_note description lists configured protected paths", () => { - const [, config] = requireCustomCall(TOOL_NAMES.VAULT_DELETE_NOTE) - expect(config.description).toContain("Profile/") - expect(config.description).not.toContain("About Me/") + it("vault_delete_note lists the configured memory dir among its protected paths", () => { + const linksEntry = findDescriptionLine({ + registeredCalls: customCalls, + toolName: TOOL_NAMES.VAULT_DELETE_NOTE, + linePrefix: "- Links to the note", + }) + expect(linksEntry).toBe( + "- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: Profile/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).", + ) + }) + + it("vault_delete_note lists PROTECTED_PATHS folders in place of the default protected paths", () => { + const linksEntry = findDescriptionLine({ + registeredCalls: registerWithConfig({ PROTECTED_PATHS: "Private,Work/Clients" }), + toolName: TOOL_NAMES.VAULT_DELETE_NOTE, + linePrefix: "- Links to the note", + }) + expect(linksEntry).toBe( + "- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: Private/, Work/Clients/.", + ) }) it("vault_delete_note description includes memory hint when memory is enabled", () => { @@ -508,7 +542,7 @@ describe("config interpolation in descriptions", () => { const exclusionDescription = config.inputSchema?.exclude_folders?.description expect(exclusionDescription).toBe( - 'Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, "Profile")', + 'Folder paths to exclude (e.g. Projects; default: the daily notes folder, "Templates", "Profile")', ) }) }) @@ -1063,44 +1097,111 @@ describe("vault_search description reflects EMBEDDING_ENABLED", () => { }) }) -describe("vault_delete_note Errors list reflects OBSIDIAN_SYNC", () => { - /** Each Errors entry up to its first em dash, in listed order. */ - const deleteNoteErrorLeads = (env: Record): string[] => { - const errorsSection = extractDescriptionSection({ +describe("vault_delete_note description reflects OBSIDIAN_SYNC", () => { + const deleteNoteErrors = (env: Record): string => { + return extractDescriptionSection({ registeredCalls: registerWithConfig(env), toolName: TOOL_NAMES.VAULT_DELETE_NOTE, startMarker: "Errors:", endMarker: "\n\nReturns:", }) - const [, ...errorEntries] = errorsSection.split("\n") - return errorEntries.map((entry) => entry.slice(0, entry.indexOf(" — "))) } + const PATH_AND_LOOKUP_ERROR_ENTRIES = [ + '- "cannot delete protected path" — the path sits under a protected folder; use vault_delete_memory for memory entries', + '- "path must end in …" — add the .md extension', + '- "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it', + '- "concurrent write in progress" — another write to this note is in flight; retry', + '- "note not found: …" — verify path with vault_list_notes', + ] + const OTHER_DELETE_ERROR_ENTRY = + '- "cannot delete …" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry' + const DAILY_NOTES_CONFIG_ERROR_ENTRY = + '- "cannot read daily notes config from .obsidian/daily-notes.json" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry' it("lists the trash-move and trash-setting errors when the server does not sync", () => { - expect(deleteNoteErrorLeads({})).toEqual([ - '- "cannot delete protected path"', - '- "path must end in …"', - '- "absolute path blocked" / "path traversal blocked" / "hidden path blocked"', - '- "concurrent write in progress"', - '- "note not found: …"', - '- "cannot move to trash …', - '- any other "cannot move to trash …"', - '- any other "cannot delete …"', - '- "cannot read trash config from .obsidian/app.json"', - '- "cannot read daily notes config from .obsidian/daily-notes.json"', - ]) + expect(deleteNoteErrors({})).toBe( + [ + "Errors:", + ...PATH_AND_LOOKUP_ERROR_ENTRIES, + '- "cannot move to trash … — 100 collisions in .trash/" — .trash/ holds this name and 100 numbered copies ("Plan 1.md" … "Plan 100.md"); ask the vault owner to clear old copies, then retry', + '- any other "cannot move to trash …" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry', + OTHER_DELETE_ERROR_ENTRY, + "- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry", + DAILY_NOTES_CONFIG_ERROR_ENTRY, + ].join("\n"), + ) }) it("leaves the trash-move and trash-setting errors out under OBSIDIAN_SYNC=true", () => { - expect(deleteNoteErrorLeads({ OBSIDIAN_SYNC: "true" })).toEqual([ - '- "cannot delete protected path"', - '- "path must end in …"', - '- "absolute path blocked" / "path traversal blocked" / "hidden path blocked"', - '- "concurrent write in progress"', - '- "note not found: …"', - '- any other "cannot delete …"', - '- "cannot read daily notes config from .obsidian/daily-notes.json"', - ]) + expect(deleteNoteErrors({ OBSIDIAN_SYNC: "true" })).toBe( + [ + "Errors:", + ...PATH_AND_LOOKUP_ERROR_ENTRIES, + OTHER_DELETE_ERROR_ENTRY, + DAILY_NOTES_CONFIG_ERROR_ENTRY, + ].join("\n"), + ) + }) + + const deleteNoteOpener = (env: Record): string | undefined => { + const deleteNoteCall = registerWithConfig(env).find( + ([toolName]) => toolName === TOOL_NAMES.VAULT_DELETE_NOTE, + ) + return deleteNoteCall?.[1].description?.split("\n")[0] + } + const deleteNoteBehavior = (env: Record): string => { + return extractDescriptionSection({ + registeredCalls: registerWithConfig(env), + toolName: TOOL_NAMES.VAULT_DELETE_NOTE, + startMarker: "Behavior:", + endMarker: "\n\nParameters:", + }) + } + const LINKS_AND_PROTECTED_PATHS_ENTRY = + "- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)." + + it("describes the Deleted files outcomes when the server does not sync", () => { + expect(deleteNoteOpener({})).toBe( + "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.", + ) + expect(deleteNoteBehavior({})).toBe( + [ + "Behavior:", + '- The "Deleted files" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:', + ' - "Move to system trash" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.', + ' - "Move to Obsidian trash" (`local`) moves the note to `.trash/` and keeps it forever.', + ' - "Permanently delete" (`none`) removes the note for good.', + "- The caller can't choose or see the outcome in advance; the returned message says which happened.", + LINKS_AND_PROTECTED_PATHS_ENTRY, + ].join("\n"), + ) + }) + + it("describes permanent deletion and Sync recovery under OBSIDIAN_SYNC=true", () => { + expect(deleteNoteOpener({ OBSIDIAN_SYNC: "true" })).toBe( + "Delete a markdown note for good: this server syncs through Obsidian Sync, so it bypasses the vault's \"Deleted files\" setting; recover a deleted note from Sync's version history (1 month on Standard, 12 months on Plus).", + ) + expect(deleteNoteBehavior({ OBSIDIAN_SYNC: "true" })).toBe( + `Behavior:\n${LINKS_AND_PROTECTED_PATHS_ENTRY}`, + ) + }) + + it("names the trash outcome in Returns only when the server does not sync", () => { + const returnsLine = (env: Record): string | undefined => { + return findDescriptionLine({ + registeredCalls: registerWithConfig(env), + toolName: TOOL_NAMES.VAULT_DELETE_NOTE, + linePrefix: "Returns:", + }) + } + const PRUNED_FOLDERS_SENTENCE = " Notes how many empty folders were pruned when any were." + + expect(returnsLine({})).toBe( + `Returns: Confirmation message naming the outcome — "Deleted " for permanent removal, "Moved to trash (<.trash/ path>)" when the note landed in .trash/.${PRUNED_FOLDERS_SENTENCE}`, + ) + expect(returnsLine({ OBSIDIAN_SYNC: "true" })).toBe( + `Returns: Confirmation message — "Deleted ".${PRUNED_FOLDERS_SENTENCE}`, + ) }) }) @@ -1635,12 +1736,15 @@ describe("vault_find_orphans live folder defaults", () => { it("describes live sources and states the exclusion default in the schema", async () => { const { toolConfig } = await setupOrphans({ settings: '{"folder":"Journal"}' }) - expect(toolConfig.description).toContain( - 'The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → "Daily Notes")', + const defaultsLine = toolConfig.description + ?.split("\n") + .find((line) => line.startsWith("- With exclude_folders")) + expect(defaultsLine).toBe( + '- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else "Daily Notes" (also used when that file is unreadable).', ) expect(toolConfig.description).not.toContain("Journal") expect(toolConfig.inputSchema?.exclude_folders?.description).toBe( - 'Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, "About Me")', + 'Folder paths to exclude (e.g. Projects; default: the daily notes folder, "Templates", "About Me")', ) }) @@ -1652,12 +1756,24 @@ describe("vault_find_orphans live folder defaults", () => { ?.split("\n") .find((line) => line.startsWith("- With exclude_folders")) expect(defaultsLine).toBe( - "- With exclude_folders omitted, the ORPHAN_EXCLUDE_FOLDERS override is used.", + "- With exclude_folders omitted, the server's configured list (the schema default) is used.", ) expect(toolConfig.inputSchema?.exclude_folders?.description).toBe( 'Folder paths to exclude (e.g. Projects; default: ["Archive","Scratch"])', ) }) + + it("leaves the daily-note hint out under an environment list, though vault_get_daily_note is served", async () => { + const { toolConfig } = await setupOrphans({ + env: { ORPHAN_EXCLUDE_FOLDERS: "Archive,Scratch" }, + }) + const excludeFoldersLine = toolConfig.description + ?.split("\n") + .find((line) => line.startsWith("- exclude_folders replaces the defaults")) + expect(excludeFoldersLine).toBe( + '- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it. Pass [] for no exclusions. Each entry names a whole folder, subfolders included ("Projects" also excludes "Projects/Archive" but not "ProjectsOld/"), ignoring ASCII letter case.', + ) + }) }) describe("vault_list_tasks handler", () => { @@ -2356,6 +2472,81 @@ describe("DISABLED_TOOLS", () => { expect(listTasksRoutingLines("vault_read_note")).toBe(`${TRIAGE_LINE_END}${SEARCH_ROUTING}`) }) + it("vault_delete_note's not-found entry keeps a remedy when vault_list_notes is disabled", () => { + const notFoundEntry = (disabledTools: string): string | undefined => { + return findDescriptionLine({ + registeredCalls: registerWithConfig({ DISABLED_TOOLS: disabledTools }), + toolName: TOOL_NAMES.VAULT_DELETE_NOTE, + linePrefix: '- "note not found', + }) + } + + expect(notFoundEntry("")).toBe('- "note not found: …" — verify path with vault_list_notes') + expect(notFoundEntry("vault_list_notes")).toBe( + `- "note not found: …" — check the path's spelling and letter case`, + ) + }) + + it("vault_get_backlinks' empty-result remedy names only served path-finding tools", () => { + const emptyResultEntry = (disabledTools: string): string | undefined => { + return findDescriptionLine({ + registeredCalls: registerWithConfig({ DISABLED_TOOLS: disabledTools }), + toolName: TOOL_NAMES.VAULT_GET_BACKLINKS, + linePrefix: "- A path nothing links to", + }) + } + const ENTRY_START = + "- A path nothing links to returns an empty result (count 0), not an error — if you expected links," + + expect(emptyResultEntry("")).toBe( + `${ENTRY_START} find valid paths with vault_search, vault_list_notes, or vault_list_files.`, + ) + expect(emptyResultEntry("vault_search,vault_list_files")).toBe( + `${ENTRY_START} find valid paths with vault_list_notes.`, + ) + expect(emptyResultEntry("vault_search,vault_list_notes,vault_list_files")).toBe( + `${ENTRY_START} check the path's spelling and letter case.`, + ) + }) + + it("vault_delete_note's broken-links entry names vault_get_backlinks only while that tool is served", () => { + const linksEntry = (disabledTools: string): string | undefined => { + return findDescriptionLine({ + registeredCalls: registerWithConfig({ DISABLED_TOOLS: disabledTools }), + toolName: TOOL_NAMES.VAULT_DELETE_NOTE, + linePrefix: "- Links to the note", + }) + } + const PROTECTED_PATHS_SENTENCE = + " Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)." + + expect(linksEntry("")).toBe( + `- Links to the note from other notes become broken; list them first with vault_get_backlinks.${PROTECTED_PATHS_SENTENCE}`, + ) + expect(linksEntry("vault_get_backlinks")).toBe( + `- Links to the note from other notes become broken.${PROTECTED_PATHS_SENTENCE}`, + ) + }) + + it("vault_find_orphans points to vault_get_daily_note for the daily folder only while that tool is served", () => { + const excludeFoldersEntry = (disabledTools: string): string | undefined => { + return findDescriptionLine({ + registeredCalls: registerWithConfig({ DISABLED_TOOLS: disabledTools }), + toolName: TOOL_NAMES.VAULT_FIND_ORPHANS, + linePrefix: "- exclude_folders replaces the defaults", + }) + } + const ENTRY_START = + "- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it" + const ENTRY_END = + '. Pass [] for no exclusions. Each entry names a whole folder, subfolders included ("Projects" also excludes "Projects/Archive" but not "ProjectsOld/"), ignoring ASCII letter case.' + + expect(excludeFoldersEntry("")).toBe( + `${ENTRY_START} (vault_get_daily_note's path starts with the daily notes folder)${ENTRY_END}`, + ) + expect(excludeFoldersEntry("vault_get_daily_note")).toBe(`${ENTRY_START}${ENTRY_END}`) + }) + it("disabling the memory write tools trims them from memory read-tool descriptions", () => { const registeredCalls = registerWithConfig({ DISABLED_TOOLS: "vault_update_memory,vault_delete_memory", diff --git a/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts b/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts index 5939338be..cbad52314 100644 --- a/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts +++ b/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts @@ -173,13 +173,11 @@ export const registerVaultOrientationPrompt = ({ ) const orphanResults = search.findOrphans( { - excludeFolders: [ - ...resolveEffectiveOrphanExcludeFolders({ - orphanExcludeFoldersOverride: config.orphanExcludeFoldersOverride, - memoryDir: config.memoryDir, - dailyNotesFolder: dailyNotesConfig.folder, - }), - ], + excludeFolders: resolveEffectiveOrphanExcludeFolders({ + orphanExcludeFoldersOverride: config.orphanExcludeFoldersOverride, + memoryDir: config.memoryDir, + dailyNotesFolder: dailyNotesConfig.folder, + }), limit: ORIENTATION_ORPHAN_LIMIT + 1, }, reqLogger, @@ -321,8 +319,8 @@ export const registerVaultOrientationPrompt = ({ brokenLinks: brokenLinkResult.count, }) return textResult(orientationSurvey) - } catch (err) { - const message = describeError(err) + } catch (error) { + const message = describeError(error) reqLogger.error("prompt_error", { error: message }) const fallbackTools = formatEnabledToolList([ "vault_list_tags", diff --git a/src/vault-mcp/mcp-core/tools/search-tools.ts b/src/vault-mcp/mcp-core/tools/search-tools.ts index 42845223c..ef3e80f2c 100644 --- a/src/vault-mcp/mcp-core/tools/search-tools.ts +++ b/src/vault-mcp/mcp-core/tools/search-tools.ts @@ -10,6 +10,7 @@ import { formatNoteMetadata, dateFilterSchema } from "./tool-helpers.js" export const registerSearchTools = ({ registerTool, safeHandler, + formatEnabledToolList, whenToolEnabledText, search, vaultPath, @@ -152,7 +153,7 @@ Returns: JSON with results array (path, title, snippet, score, tags, folder, typ TOOL_NAMES.VAULT_SEARCH_BY_TAG, { title: "Search by Tag", - description: `Find notes with a specific tag. By default uses hierarchical prefix matching — a parent tag matches all children (e.g. "project" matches "project/vault-cortex", "project/blog"). Set exact=true for exact match only. + description: `Find notes with a specific frontmatter tag (inline #tags are not indexed). By default uses hierarchical prefix matching — a parent tag matches all children (e.g. "project" matches "project/vault-cortex", "project/blog"). Set exact=true for exact match only. Example: vault_search_by_tag({ tag: "project" }) @@ -160,12 +161,12 @@ When to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no tex Prefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags. Parameters: -- tag + exact interact: the prefix match follows the "/" separator, so "project" matches itself and its children but does NOT match "my-project" or "projects". exact=true matches only the literal tag, excluding children. +- Prefix mode follows the "/" separator: "project" matches itself and every tag nested under it (project/a, project/a/b) but does NOT match "my-project" or "projects". Errors: -- An unknown tag or no matches returns an empty array, not an error — don't use as an existence check. +- An unknown tag or no matches returns an empty array, not an error. -Returns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.`, +Returns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified and capped at 20, with no truncation signal: exactly 20 results may mean more exist. bytes is the on-disk file size. additional_properties holds only frontmatter keys without their own top-level field.`, inputSchema: { tag: z .string() @@ -244,19 +245,19 @@ When to use: Catching up on vault changes, finding recent work, or orienting aft Prefer vault_search for content-based discovery. Prefer vault_search_by_folder for browsing a specific folder. Parameters: -- sort_by + limit interact: "modified" (default) uses filesystem mtime, so every note has a value and limit works predictably. "created" uses the frontmatter created property — notes without it sort last (not excluded), so a small limit may return only notes that have the property; increase limit or use "modified" for broader coverage. +- sort_by + limit interact: "modified" (default) uses filesystem mtime, which every note has. "created" uses the frontmatter created property; notes without a valid one sort after every dated note, so a small limit can leave them out — use "modified" to see them. - "modified" includes any file write (content edits, property changes, sync touches), so recently-synced notes appear recent even without user edits. Errors: - An empty vault returns an empty array, not an error. -Returns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted descending by chosen timestamp. created is null when the property is missing; bytes is on-disk file size.`, +Returns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted descending by chosen timestamp. created is null when the property is missing or not an ISO date; bytes is on-disk file size.`, inputSchema: { sort_by: z .enum(["created", "modified"]) .optional() .default("modified") - .describe('Sort order (default "modified")'), + .describe('Timestamp to sort by, newest first (default "modified")'), limit: z .number() .int() @@ -444,7 +445,7 @@ Returns: JSON array of { value, count } sorted by count descending, then by valu TOOL_NAMES.VAULT_SEARCH_BY_PROPERTY, { title: "Search by Property", - description: `Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: "active") and array properties (tags, related): for arrays, matches if any element equals the value (contains check, not exact array match). + description: `Find notes where a frontmatter property matches a value — metadata-only search, no text query needed. Handles both scalar properties (status: "active") and array properties (tags, related): for arrays, matches if any element equals the value. Example: vault_search_by_property({ key: "status", value: "in-progress" }) Example: vault_search_by_property({ key: "type", value: "session-log", folder: "Code Projects" }) @@ -453,35 +454,30 @@ When to use: Finding notes by metadata when you don't have a text query. Prefer vault_search when you also have a text query (it supports property filters too). Prefer vault_search_by_tag for tag-specific queries (supports hierarchical prefix matching). Use vault_list_property_keys to discover valid keys and vault_list_property_values to see what values a key takes. Parameters: -- key is exact and case-sensitive. Text values match exactly and case-sensitively, with no partial matching or globbing. Stored numbers also match numerically: "04" and "4.0" match number 4 and their own literal text, but not text "4". -- Numeric matching accepts complete finite YAML core numeric forms: signed decimals, leading-zero decimals, .5, 4., exponents, 0x hexadecimal and 0o octal. Whitespace, final line breaks, prefixes like "4abc", comments, expressions, 0b binary, separators, non-finite values and overflow match only literal text. -- Numeric equality uses stored number precision: large integers can round to the same value, and underflow such as "1e-999" matches stored zero. -- Pass a checkbox as "1" or "0" (true is stored as 1, false as 0); "1.0" does not match a checked checkbox. +- key matches exactly and case-sensitively; value matches stored text the same way, with no partial matching or globbing. +- A value written as a complete, finite YAML number (-4, 04, .5, 4., 1e3, 0x1F, 0o17) also matches stored numbers numerically: "04" and "4.0" match number 4 and their own literal text, but not text "4". Anything else ("4abc", " 4", 0b binary, "1e999") matches only as text. Numbers compare as 64-bit floats, so integers past 2^53 can compare equal and "1e-999" matches 0. +- Pass a checkbox as "1" (checked) or "0" (unchecked); a checkbox matches only as text, so "1.0" and "true" do not match. - An array element must equal value in full: "blog" matches tags: ["blog", "draft"] but not tags: ["my-blog"]. - folder names a whole folder and includes its subfolders: "Projects" covers "Projects/Archive" but not "ProjectsOld/". Matching ignores ASCII letter case; omit folder to search the entire vault. -- limit applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check. +- limit keeps the most recently modified matches. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check. Errors: - An unknown key or unmatched value returns an empty array, not an error. -Returns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits. +Returns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by filesystem mtime descending — recently-synced notes may sort ahead of older content edits. - leading_callout appears only when the note has a leading callout. - additional_properties appears only when frontmatter has keys outside title, tags, type, created, and related.`, inputSchema: { - key: z - .string() - .min(1) - .describe( - 'Property key name (e.g. "status", "type", "tags"). Use vault_list_property_keys to discover valid keys.', - ), - value: z - .string() - .min(1) - .describe( - 'Value to match (e.g. "active", "4", "1e-7"). Use vault_list_property_values to discover valid values for a key.', - ), + key: z.string().min(1).describe('Property key name (e.g. "status", "type", "tags").'), + value: z.string().min(1).describe('Value to match (e.g. "active", "4", "1e-7").'), folder: z.string().min(1).optional().describe('Restrict to a folder (e.g. "Projects")'), - limit: z.number().int().min(1).optional().default(20).describe("Max results (default 20)"), + limit: z + .number() + .int() + .min(1) + .optional() + .default(20) + .describe("Max results (default 20, no upper cap)"), }, }, async ({ key, value, folder, limit }, extra) => { @@ -501,24 +497,38 @@ Returns: JSON array of note metadata (path, title, tags, related, folder, type, }, ) + // Every path-finding tool can be dropped via DISABLED_TOOLS, so the remedy + // falls back to checking the path itself when none is served. + const backlinksPathFinders = formatEnabledToolList([ + TOOL_NAMES.VAULT_SEARCH, + TOOL_NAMES.VAULT_LIST_NOTES, + TOOL_NAMES.VAULT_LIST_FILES, + ]) + const backlinksEmptyResultRemedy = + backlinksPathFinders.length > 0 + ? `find valid paths with ${backlinksPathFinders}` + : "check the path's spelling and letter case" + registerTool( TOOL_NAMES.VAULT_GET_BACKLINKS, { title: "Get Backlinks", - description: `Find all notes and files that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas file-node references. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks. + description: `Find all notes and canvases that link to a given note or canvas — captures [[wikilinks]], [markdown](links), ![[embeds]], wikilinks inside frontmatter properties (e.g. related:), and canvas cards that embed the file. Heading anchors ([[note#heading]]) and aliases ([[note|alias]]) resolve as backlinks to the base note. Links inside code blocks are ignored; a note linking to itself appears in its own backlinks. Example: vault_get_backlinks({ path: "Projects/vault-cortex.md" }) Example: vault_get_backlinks({ path: "Diagrams/architecture.canvas" }) When to use: Understanding what references a note or canvas, assessing its connectivity before editing or deleting, or finding related notes via the graph. -For outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes with no backlinks at all, use vault_find_orphans. +For outgoing links (what a note links TO), use vault_get_outgoing_links. To find notes nothing else links to, use vault_find_orphans. Parameters: - path: exact vault-relative path including .md or .canvas extension, case-sensitive. Returns: JSON with path (the queried note or canvas), backlinks (array of { path, title, bytes } sorted by title), and count. Backlink sources may be notes (.md) or canvas files (.canvas). -Errors: Rejects paths that don't end in .md or .canvas. A non-indexed path returns an empty result (count 0), not an error — use vault_list_notes or vault_search to discover valid paths.`, +Errors: +- "path must end in …" — add the .md or .canvas extension +- A path nothing links to returns an empty result (count 0), not an error — if you expected links, ${backlinksEmptyResultRemedy}.`, inputSchema: { path: z .string() @@ -614,10 +624,18 @@ Errors: const orphanDefaultFolders = config.orphanExcludeFoldersOverride ? JSON.stringify(config.orphanExcludeFoldersOverride) - : `daily notes folder, Templates, ${JSON.stringify(config.memoryDir)}` + : `the daily notes folder, "Templates", ${JSON.stringify(config.memoryDir)}` const orphanDefaultDescription = config.orphanExcludeFoldersOverride - ? "With exclude_folders omitted, the ORPHAN_EXCLUDE_FOLDERS override is used." - : 'The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → "Daily Notes"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses "Daily Notes".' + ? "With exclude_folders omitted, the server's configured list (the schema default) is used." + : 'With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else "Daily Notes" (also used when that file is unreadable).' + // An override list may not include the daily notes folder, so the hint only + // accompanies the built-in defaults. + const dailyNotesFolderHint = config.orphanExcludeFoldersOverride + ? "" + : whenToolEnabledText( + "vault_get_daily_note", + " (vault_get_daily_note's path starts with the daily notes folder)", + ) registerTool( TOOL_NAMES.VAULT_FIND_ORPHANS, @@ -626,14 +644,13 @@ Errors: description: `Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored). Example: vault_find_orphans({}) -Example: vault_find_orphans({ exclude_folders: ["Archive"], limit: 10 }) When to use: Vault maintenance — surfacing notes to integrate into the graph.${whenToolEnabledText("vault_patch_note", " Link an orphan by mentioning it from a relevant note with vault_patch_note.")} Prefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault. Parameters: - ${orphanDefaultDescription} -- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included ("Projects" also excludes "Projects/Archive" but not "ProjectsOld/"), ignoring ASCII letter case. +- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it${dailyNotesFolderHint}. Pass [] for no exclusions. Each entry names a whole folder, subfolders included ("Projects" also excludes "Projects/Archive" but not "ProjectsOld/"), ignoring ASCII letter case. - limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check. Errors: @@ -661,13 +678,7 @@ Returns: JSON array of note metadata (path, title, tags, related, folder, type, const excludeFolders = exclude_folders ?? (await readEffectiveOrphanExcludeFolders({ config, vaultPath }, reqLogger)) - return search.findOrphans( - { - excludeFolders: [...excludeFolders], - limit, - }, - reqLogger, - ) + return search.findOrphans({ excludeFolders, limit }, reqLogger) }, (results) => { reqLogger.info("tool_result", { resultCount: results.length }) diff --git a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts index 4f3f79223..c4d3c1260 100644 --- a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts +++ b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts @@ -320,14 +320,14 @@ When to use: Browsing what exists in a folder by filename, or finding notes matc Prefer vault_search_by_folder when you need metadata (tags, type, related) along with paths. Prefer vault_search for content-based discovery. Use vault_read_note to read a note from the results. Parameters: -- folder names a whole folder and includes its subfolders: "Projects" covers "Projects/Archive" but not "ProjectsOld/". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing. +- folder names a whole folder; without a glob, notes in its subfolders are listed too: "Projects" covers "Projects/Archive" but not "ProjectsOld/". This tool reads the filesystem rather than the search index, so use the folder's exact letter case, as other results show it; on a case-sensitive filesystem a different case finds nothing. - glob matches each note's path inside folder (its vault-relative path when folder is omitted), case-sensitively. * stays within one folder level and ** spans any depth: with folder "Projects", "*.md" lists the folder's top-level notes and "**/*.md" every note under it. Returned paths are always vault-relative. -Behavior: Paths come back sorted by vault-relative path, uppercase before lowercase. Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included. +Behavior: Paths come back sorted by vault-relative path in code-unit order (uppercase before lowercase). Hidden (dot-prefixed) notes and folders are never listed, matching Obsidian; symlinked notes are included. Errors: - A nonexistent folder or no glob matches returns an empty array, not an error. -- "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — the folder starts at the filesystem root, escapes the vault or names the vault root itself (e.g. "."), or is hidden like ".obsidian"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault. +- "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — use a vault-relative folder outside hidden folders, or omit folder to list the whole vault. Returns: JSON array of vault-relative path strings (e.g. ["Notes/idea.md", "Projects/plan.md"]).`, inputSchema: { @@ -489,7 +489,8 @@ Editing a leading callout: read it via vault_read_note(outline: true), then vaul ), ]) - // The edit tools' Errors entries keep a remedy when the tool they point to is disabled. + // The edit and delete tools' not-found remedies name vault_list_notes or vault_read_note + // only while that tool is served; otherwise they fall back to a check that needs no tool. const listNotesEnabled = isToolEnabled("vault_list_notes") const noteNotFoundCheckRemedy = listNotesEnabled ? "check vault_list_notes for valid paths" @@ -1033,36 +1034,45 @@ Returns: Confirmation message "Inserted lines anchor in ".` + : `Confirmation message naming the outcome — "Deleted " for permanent removal, "Moved to trash (<.trash/ path>)" when the note landed in .trash/.` registerTool( TOOL_NAMES.VAULT_DELETE_NOTE, { title: "Delete Note", - description: `Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian "Deleted files" setting directs. + description: `${deleteNoteOpener} Example: vault_delete_note({ path: "Scratch/temp.md" }) Example: vault_delete_note({ path: "Archive/2024/old.md", prune_empty_folders: true }) — also remove "Archive/2024" (and "Archive") if deleting the note empties them. When to use: Removing a note you no longer need.${whenToolEnabledText("vault_delete_memory", `\nPrefer vault_delete_memory for removing individual dated entries from ${config.memoryDir}/ memory files.`)}${whenToolEnabledText("vault_move_note", "\nTo relocate a note, use vault_move_note instead.")}${whenToolEnabledText("vault_write_note", "\nTo replace a note's content, use vault_write_note with overwrite: true instead.")} -Behavior: -- Unless the server syncs through Obsidian Sync, the "Deleted files" setting (\`trashOption\` in \`.obsidian/app.json\`) decides the outcome: - - "Move to system trash" (\`system\`, also what an absent setting means) moves the note to \`.trash/\`, since the server has no system trash. The server deletes its own copies there after its TRASH_RETENTION_DAYS setting (default 30 days, or never when set to none), never touching notes Obsidian trashed. - - "Move to Obsidian trash" (\`local\`) moves the note to \`.trash/\` and keeps it forever. - - "Permanently delete" (\`none\`) removes the note for good. -- When the server syncs through Obsidian Sync, the setting is bypassed and the note is always deleted for good; recover it from Sync's version history (1 month on Standard, 12 months on Plus). -- The caller can't choose or see the outcome in advance; the returned message says which happened. -- Links to the note from other notes become broken${whenToolEnabledText("vault_get_backlinks", " (detectable via vault_get_backlinks)")}. Protected paths (${describeProtectedPaths(config)}) are refused. +Behavior:${trashOutcomeEntries} +- Links to the note from other notes become broken${whenToolEnabledText("vault_get_backlinks", "; list them first with vault_get_backlinks")}. Protected paths are refused: ${describeProtectedPaths(config)}. Parameters: - prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian. @@ -1072,11 +1082,11 @@ Errors: - "path must end in …" — add the .md extension - "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it - "concurrent write in progress" — another write to this note is in flight; retry -- "note not found: …" — the note does not exist${whenToolEnabledText("vault_list_notes", "; verify the path with vault_list_notes before deleting")}${trashMoveErrorEntries} -- any other "cannot delete …" — the permanent delete failed (e.g. permissions); the note stays put; fix the cause, then retry${trashConfigErrorEntry} -- "cannot read daily notes config from .obsidian/daily-notes.json" — the file exists but is unreadable, so the daily notes folder to protect is unknown; repair it, or set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry +- "note not found: …" — ${noteNotFoundVerifyRemedy}${trashMoveErrorEntries} +- "cannot delete …" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry${trashConfigErrorEntry} +- "cannot read daily notes config from .obsidian/daily-notes.json" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry -Returns: Confirmation message naming the outcome — "Deleted " for permanent removal, "Moved to trash ()" when the note landed in .trash/. Notes how many empty folders were pruned when any were.`, +Returns: ${deleteNoteReturns} Notes how many empty folders were pruned when any were.`, inputSchema: { path: z .string() @@ -1123,12 +1133,14 @@ Returns: Confirmation message naming the outcome — "Deleted " for perman pruneEmptyFolders, trashOption, // Only "system" moves are swept later, so only they are recorded; - // "local" is the user's keep-forever trash. + // "local" is Obsidian's keep-forever trash. recordTrashEntry: trashOption === "system" ? search.recordTrashEntry : undefined, - // Always passed. If an earlier delete left a row for this .trash/ - // path (the user emptied .trash/ by hand), the sweep would still - // remove whatever lands there, including a "local" note meant - // to be kept. + // The retention sweep deletes the file at each expired + // trash_entries row's path, and a row can outlive its file when + // .trash/ is emptied by hand. A move that does not record clears + // the row at its landed path, so the sweep cannot remove the new + // file, such as a "local" note meant to be kept. A "none" delete + // lands nothing in .trash/ and never calls it. clearStaleTrashEntry: search.deleteTrashEntry, }, reqLogger, diff --git a/src/vault-mcp/search/search-queries.ts b/src/vault-mcp/search/search-queries.ts index 364c536d3..1e20e9d5b 100644 --- a/src/vault-mcp/search/search-queries.ts +++ b/src/vault-mcp/search/search-queries.ts @@ -1482,7 +1482,7 @@ export const getOutgoingLinks = ( /** Finds notes with no incoming links (orphans). */ export const findOrphans = ( context: SearchQueryContext, - params: { excludeFolders?: string[] | undefined; limit?: number | undefined }, + params: { excludeFolders?: readonly string[] | undefined; limit?: number | undefined }, logger: Logger, ): NoteMetadata[] => { const excludeFolders = params.excludeFolders ?? []