diff --git a/AGENTS.md b/AGENTS.md index 858f95839..671f6aa91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -818,11 +818,11 @@ which are the description and the input schema with each parameter's Zod which lists only the messages that tool can raise: for example `"absolute path blocked"`, `"path traversal blocked"`, and `"hidden path blocked"`. -- **Parameter text has one home per kind of fact.** The calling model - receives each parameter's Zod `.describe()` text inside the input - schema, so the tool description earns its space (and its score from - graders such as Glama's Tool Definition Quality Score, TDQS) only - with what that text doesn't say: +- **Split parameter facts between `.describe()` and the tool description.** + The calling model receives each parameter's Zod `.describe()` text + inside the input schema, so the tool description earns its space + (and its score from graders such as Glama's Tool Definition Quality + Score, TDQS) only with what that text doesn't say: - `.describe()` gives the parameter's plain meaning: what it is, its format, and its default. - The description gives what `.describe()` can't: interactions @@ -841,6 +841,9 @@ which are the description and the input schema with each parameter's Zod second copy of a guard the data layer must enforce regardless (drift risk). `.min(1)` is the floor because it does serialize (`minLength`) and its default failure message is self-explanatory. +- **Edit only the tools a change is meant for.** A grader re-scores + every definition whose text changes, so apply a shared wording change + tool by tool and leave every other tool's text as it is. - **`tool-surface-snapshot.test.ts` caps the tool list's size**, because clients such as claude.ai load every definition into each conversation. Each checked combo's total must stay within @@ -1118,7 +1121,8 @@ test. - New config gating axis → config matrix test in `server-integration.test.ts` (tool count + key behavior), and add the axis to `SURFACE_AXES` in `scripts/tool-surface-capture.ts` so the - snapshot combos cover it. + snapshot combos cover it. For a setting that changes only one tool's + text, add a single combo to `SURFACE_COMBOS` instead. - New prompt → assembly test verifying live vault data, not just the instruction wrapper. diff --git a/README.md b/README.md index 0490e753b..2226594ce 100644 --- a/README.md +++ b/README.md @@ -420,7 +420,7 @@ When the file isn't available — or if you use the Periodic Notes plugin, whose You can set one or both — a set value always wins over the config file. Without either source, the server falls back to `Daily Notes` and `YYYY-MM-DD`. -> **Note:** A few date format tokens are unsupported — ordinals (`Do`, `Mo`, `DDDo`, `wo`), `dd` (2-letter weekday), `d` (weekday number), `e`, `k`/`kk`, and the localized formats (`L`–`LLLL`, `LT`, `LTS`). The server can't reproduce the filenames Obsidian creates with these tokens, so it could never find the notes. If your format uses any of them, `vault_get_daily_note` returns a clear error — change the format in Obsidian or set `DAILY_NOTES_FORMAT` to a supported alternative. +> **Note:** A few date format tokens are unsupported — ordinals (`Do`, `Mo`, `DDDo`, `wo`), `dd` (2-letter weekday), `d` (weekday number), `e`, `k`/`kk`, `w` (week number), `Q` (quarter), `Z`/`ZZ` (UTC offset), and the localized formats (`L`–`LLLL`, `LT`, `LTS`). The server can't reproduce the filenames Obsidian creates with these tokens, so it could never find the notes. If your format uses any of them, `vault_get_daily_note` returns a clear error — change the format in Obsidian or set `DAILY_NOTES_FORMAT` to a supported alternative. --- diff --git a/scripts/tool-surface-capture.ts b/scripts/tool-surface-capture.ts index 2f98c1d32..98b98371f 100644 --- a/scripts/tool-surface-capture.ts +++ b/scripts/tool-surface-capture.ts @@ -27,9 +27,11 @@ type SurfaceAxis = { label: string } -/** Boolean config axes that change the registered tool surface or its rendered - * text. The snapshot combos are the cross-product of this array — a new - * gating axis in config.ts must be added here, or its states go unpinned. */ +/** Boolean config axes that change which tools are registered or the text of + * several tools. The snapshot combos are the cross-product of this array — a + * new gating axis in config.ts must be added here, or its states go unpinned. + * A setting that changes one tool's text gets a single combo in + * SURFACE_COMBOS instead. */ const SURFACE_AXES: readonly SurfaceAxis[] = [ { envVar: "READONLY_MODE", flippedValue: "true", label: "readonly" }, { envVar: "MEMORY_ENABLED", flippedValue: "false", label: "memory-off" }, @@ -71,14 +73,19 @@ const comboFromFlippedAxes = (flippedAxes: readonly SurfaceAxis[]): SurfaceCombo } } -/** The 16 axis combos plus one DISABLED_TOOLS representative: - * - vault_patch_note is cross-referenced from other tools' descriptions, so - * its combo verifies those references disappear when the tool is disabled. +/** The 16 axis combos plus two single-setting representatives: * - Conjunction combos pin rendered states that exist only in multi-flip - * configs (several description clauses drop together). */ + * configs (several description clauses drop together). + * - 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 + * baseline without adding a rendered state. */ export const SURFACE_COMBOS: readonly SurfaceCombo[] = [ ...axisSubsets.map(comboFromFlippedAxes), { name: "disabled-tools", env: { DISABLED_TOOLS: "vault_patch_note" } }, + { name: "obsidian-sync", env: { OBSIDIAN_SYNC: "true" } }, ] const noop = (): void => {} @@ -192,11 +199,11 @@ export const measureToolListChars = (tools: readonly Tool[]): number => { * - embedding-off renders the search tools' keyword-only text, which default * never shows. * - * Every other combo drops tools or cross-references from one of these two, so - * its total is smaller than a checked total. Its average per tool can still - * exceed that cap's `CHARS_PER_TOOL_ALLOWANCE` (memory-off drops five small - * tools), so the allowance bounds the two checked lists, not every combo's - * average. */ + * Every other combo drops tools, cross-references, or Errors entries from one + * of these two, so its total is smaller than a checked total. Its average per + * tool can still exceed that cap's `CHARS_PER_TOOL_ALLOWANCE` (memory-off + * drops five small tools), so the allowance bounds the two checked lists, not + * every combo's average. */ export const SIZE_CAPPED_COMBO_NAMES = ["default", "embedding-off"] as const /** Byte-exact committed form: pre-serialized so vitest writes the file diff --git a/src/__tests__/integration/server-error-contracts.test.ts b/src/__tests__/integration/server-error-contracts.test.ts index 911aab822..75ab8a128 100644 --- a/src/__tests__/integration/server-error-contracts.test.ts +++ b/src/__tests__/integration/server-error-contracts.test.ts @@ -2,7 +2,7 @@ * verified over real HTTP transport against a real server. */ import { describe, it, expect, beforeAll, afterAll, onTestFinished, vi } from "vitest" -import { readFile, writeFile } from "node:fs/promises" +import { mkdir, readFile, rm, writeFile } from "node:fs/promises" import { join } from "node:path" import type { Client } from "@modelcontextprotocol/sdk/client/index.js" import { @@ -571,6 +571,24 @@ describe("note already exists", () => { }) }) +// ── Path is not a file ─────────────────────────────────────── + +describe("path is not a file", () => { + it("vault_write_note refuses a path that a folder occupies", async () => { + const folderPath = "Folder Named Like A Note.md" + const folderFullPath = join(serverVaultPath, folderPath) + await mkdir(folderFullPath) + onTestFinished(() => rm(folderFullPath, { recursive: true })) + + const result = await callTool({ + client, + name: "vault_write_note", + args: { path: folderPath, body: "refused" }, + }) + expectToolError(result, `cannot write note "${folderPath}": that path is not a file`) + }) +}) + // ── Heading not found ──────────────────────────────────────── describe("heading not found", () => { @@ -784,6 +802,220 @@ describe("memory errors", () => { }) expectToolError(result, 'section not found: "Missing Section" in About Me/Preferences.md') }) + + it("vault_delete_memory with a nonexistent file", async () => { + const result = await callTool({ + client, + name: "vault_delete_memory", + args: { + file: "Nonexistent", + section: "Editor settings", + date: "2026-01-01", + entry: "anything", + }, + }) + expectToolError(result, 'memory file not found: "About Me/Nonexistent.md"') + }) + + it("vault_get_memory with a folder in the file name", async () => { + const result = await callTool({ + client, + name: "vault_get_memory", + args: { file: "Nested/Preferences" }, + }) + expectToolError( + result, + 'memory file must be a bare name without path separators: "Nested/Preferences"', + ) + }) + + it("vault_update_memory with a folder in the file name", async () => { + const result = await callTool({ + client, + name: "vault_update_memory", + args: { file: "Nested/Preferences", section: "Editor settings", entry: "never written" }, + }) + expectToolError( + result, + 'memory file must be a bare name without path separators: "Nested/Preferences"', + ) + }) + + it("vault_update_memory with a control character in the entry", async () => { + const result = await callTool({ + client, + name: "vault_update_memory", + args: { file: "Preferences", section: "Editor settings", entry: "bell\u0007inside" }, + }) + expectToolError(result, "entry contains a control character (U+0007 at position 4)") + }) + + it("vault_update_memory with a control character in the section", async () => { + const result = await callTool({ + client, + name: "vault_update_memory", + args: { file: "Preferences", section: "Editor\u0007settings", entry: "never written" }, + }) + expectToolError(result, "section contains a control character (U+0007 at position 6)") + }) + + it("vault_delete_memory with a folder in the file name", async () => { + const result = await callTool({ + client, + name: "vault_delete_memory", + args: { + file: "Nested/Preferences", + section: "Editor settings", + date: "2026-01-01", + entry: "anything", + }, + }) + expectToolError( + result, + 'memory file must be a bare name without path separators: "Nested/Preferences"', + ) + }) +}) + +// ── Parameter combinations ─────────────────────────────────── + +describe("parameter combinations", () => { + it("vault_read_note with heading_level but no heading", async () => { + const result = await callTool({ + client, + name: "vault_read_note", + args: { path: "Projects/alpha.md", heading_level: 2 }, + }) + expectToolError(result, "heading_level requires a heading") + }) + + it("vault_read_note with a whitespace-only heading", async () => { + const result = await callTool({ + client, + name: "vault_read_note", + args: { path: "Projects/alpha.md", heading: " " }, + }) + expectToolError(result, "heading cannot be empty") + }) + + it("vault_patch_note with a whitespace-only heading", async () => { + const result = await callTool({ + client, + name: "vault_patch_note", + args: { path: "Projects/alpha.md", operation: "append", heading: " ", content: "refused" }, + }) + expectToolError(result, "heading cannot be empty") + }) + + it("vault_move_note onto its own path", async () => { + const result = await callTool({ + client, + name: "vault_move_note", + args: { old_path: "Projects/alpha.md", new_path: "Projects/alpha.md" }, + }) + expectToolError(result, "source and destination are the same path") + }) +}) + +// ── Task tool path guards ──────────────────────────────────── + +describe("task tool path guards", () => { + const blockedPathCases = [ + { label: "an absolute path", path: "/TASKS.md", message: "absolute path blocked" }, + { label: "a path outside the vault", path: "../TASKS.md", message: "path traversal blocked" }, + { label: "a hidden path", path: ".obsidian/TASKS.md", message: "hidden path blocked" }, + ] + + it.each(blockedPathCases)("vault_create_task rejects $label", async ({ path, message }) => { + const result = await callTool({ + client, + name: "vault_create_task", + args: { path, description: "never written", block_id: "never-written" }, + }) + expectToolError(result, message) + }) + + it.each(blockedPathCases)("vault_update_task rejects $label", async ({ path, message }) => { + const result = await callTool({ + client, + name: "vault_update_task", + args: { path, block_id: "never-read", status: "done" }, + }) + expectToolError(result, message) + }) +}) + +// ── Malformed canvas ───────────────────────────────────────── + +describe("malformed canvas", () => { + it("vault_read_file on a .canvas that is not valid JSON", async () => { + const canvasPath = "Not A Canvas.canvas" + const canvasFullPath = join(serverVaultPath, canvasPath) + await writeFile(canvasFullPath, "{ not json", "utf8") + onTestFinished(() => rm(canvasFullPath)) + + const result = await callTool({ client, name: "vault_read_file", args: { path: canvasPath } }) + expectToolError(result, "invalid .canvas JSON") + }) +}) + +// ── Whitespace-only task text ──────────────────────────────── + +describe("whitespace-only task text", () => { + it("vault_create_task with a whitespace-only description", async () => { + const result = await callTool({ + client, + name: "vault_create_task", + args: { path: "Projects/alpha.md", description: " ", block_id: "blank-description" }, + }) + expectToolError(result, "description is empty") + }) + + it("vault_create_task with a whitespace-only checklist item", async () => { + const result = await callTool({ + client, + name: "vault_create_task", + args: { + path: "Projects/alpha.md", + description: "Card with a blank checklist item", + block_id: "blank-checklist-item", + subtasks: [" "], + }, + }) + expectToolError(result, "subtasks cannot contain an empty item") + }) + + it("vault_update_task with a whitespace-only description", async () => { + const result = await callTool({ + client, + name: "vault_update_task", + args: { path: "Projects/alpha.md", block_id: "alpha-task-1", description: " " }, + }) + expectToolError(result, "description cannot be empty") + }) + + it("vault_update_task with a whitespace-only checklist item", async () => { + const result = await callTool({ + client, + name: "vault_update_task", + args: { path: "Projects/alpha.md", block_id: "alpha-task-1", add_subtasks: [" "] }, + }) + expectToolError(result, "addSubtasks cannot contain an empty item") + }) +}) + +// ── Undecodable image ──────────────────────────────────────── + +describe("undecodable image", () => { + it("vault_read_file on a .png that holds no image data", async () => { + const imagePath = "Not Really An Image.png" + const imageFullPath = join(serverVaultPath, imagePath) + await writeFile(imageFullPath, "plain text, not image bytes", "utf8") + onTestFinished(() => rm(imageFullPath)) + + const result = await callTool({ client, name: "vault_read_file", args: { path: imagePath } }) + expectToolError(result, "Input buffer contains unsupported image format") + }) }) // ── Task errors ────────────────────────────────────────────── diff --git a/src/__tests__/integration/server-integration.test.ts b/src/__tests__/integration/server-integration.test.ts index 50c199c6b..a4db4620b 100644 --- a/src/__tests__/integration/server-integration.test.ts +++ b/src/__tests__/integration/server-integration.test.ts @@ -1890,6 +1890,37 @@ describe("trash retention over real HTTP", () => { expect(readTrashEntryRows(server.dataDir)).toEqual([]) }, 30_000) + it("a sync-mode delete succeeds with an unreadable Deleted files setting and a blocked .trash", async () => { + // vault_delete_note's description leaves its trash errors out under Sync. + // Without Sync, the first condition fails the delete with "cannot read + // trash config" and the second with "cannot move to trash". + const server = await startServer(await freePort(), { + OBSIDIAN_SYNC: "true", + }) + onTestFinished(() => server.cleanup()) + await writeFile(join(server.vaultPath, ".obsidian", "app.json"), "{ not json", "utf8") + await writeFile(join(server.vaultPath, ".trash"), "a file where the trash folder goes", "utf8") + const client = await createTestClient(server.port) + onTestFinished(() => client.close()) + + await callTool({ + client, + name: "vault_write_note", + args: { path: "Scratch/sync-blocked-trash.md", body: "gone for good" }, + }) + const deleteResult = await callTool({ + client, + name: "vault_delete_note", + args: { path: "Scratch/sync-blocked-trash.md" }, + }) + + expect(deleteResult.isError).not.toBe(true) + expect(textContent(deleteResult)).toBe("Deleted Scratch/sync-blocked-trash.md") + await expect( + fileExists(join(server.vaultPath, "Scratch", "sync-blocked-trash.md")), + ).resolves.toBe(false) + }, 30_000) + it("a Deleted files setting changed between two deletes is followed without a restart", async () => { const server = await startServer(await freePort()) onTestFinished(() => server.cleanup()) 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 e5f7c6fbe..b238b644c 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 @@ -6,7 +6,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- heading is required on Kanban boards (notes with kanban-plugin frontmatter).\n- parent_block_id / parent_line: the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead of \"bottom\". Ignored when no heading or when placing under a parent.\n- priority: the plugin ranks \"no signifier\" (normal priority) between medium and low.\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day.\n- due / scheduled / start: omit a date rather than guessing — an absent 📅 means \"no deadline\".\n\nErrors:\n- \"note not found\" — path does not exist\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- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox \"[c]\" is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -26,23 +26,23 @@ "description": "The ^block-id for stable identification — letters, digits, and hyphens only ([a-zA-Z0-9-]+). Must be unique within the note." }, "heading": { - "description": "Target heading. Required on Kanban boards; optional on regular notes (omit to append at end of body).", + "description": "Target heading. On a regular note, omit to append at end of body.", "type": "string", "minLength": 1 }, "parent_block_id": { - "description": "^block-id (without the ^) of an existing task to nest under as a sub-task. Mutually exclusive with parent_line and heading.", + "description": "^block-id (without the ^) of an existing task to nest under as a sub-task.", "type": "string", "minLength": 1 }, "parent_line": { - "description": "1-based line number of an existing task to nest under as a sub-task. Mutually exclusive with parent_block_id and heading. Fragile if the file changed since the line was read.", + "description": "1-based line number of an existing task to nest under as a sub-task. Fragile if the file changed since the line was read.", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "position": { - "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to bottom. Kanban boards with new-card-insertion-method set to prepend default to top instead. Ignored when no heading or when placing under a parent.", + "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to bottom.", "anyOf": [ { "type": "string", @@ -70,7 +70,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", "type": "string", "minLength": 1 }, @@ -149,7 +149,7 @@ { "name": "vault_delete_memory", "title": "Delete Memory Entry", - "description": "Delete a single dated entry from a About Me/ memory file. Both date and entry text are required for exact matching — ensures only the intended entry is removed.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"AI tooling & memory (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: when a preference or fact has since evolved, append the new state via vault_update_memory (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_update_memory to supersede a changed entry; prefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together uniquely identify the bullet line within the given section. If multiple entries share the same date and text, deletion fails as ambiguous.\n- section scopes the match — an identical entry under a different heading is not found. Section matching is case-insensitive, with or without the \"(newest first)\" suffix.\n\nErrors:\n- \"memory file must not start with a dot\" — a dot-prefixed name would target a hidden file; memory files are always visible notes.\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section (e.g. from hand edits, sync conflicts, or entries predating duplicate protection; vault_update_memory refuses to write exact duplicates). Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content before retrying.\n\nReturns: Confirmation message.", + "description": "Delete a single dated entry from a memory file in About Me/. Both date and entry text are required for exact matching — ensures only the intended entry is removed.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"AI tooling & memory (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together uniquely identify the bullet line within the given section. If multiple entries share the same date and text, deletion fails as ambiguous.\n- section scopes the match — an identical entry under a different heading is not found. Section matching is case-insensitive, with or without the \"(newest first)\" suffix.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would target a hidden file; memory files are always visible notes).\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section (e.g. from hand edits, sync conflicts, or entries predating duplicate protection; vault_update_memory refuses to write exact duplicates). Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -171,7 +171,7 @@ "entry": { "type": "string", "minLength": 1, - "description": "Exact entry text as shown by vault_get_memory — without the \"- **YYYY-MM-DD**: \" prefix or bullet. Both date and entry must match for deletion." + "description": "Exact entry text as shown by vault_get_memory — without the \"- **YYYY-MM-DD**: \" prefix or bullet." } }, "required": [ @@ -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\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\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- \"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 rather than risk skipping a configured .trash/; 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- 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.", "inputSchema": { "type": "object", "properties": { @@ -228,7 +228,7 @@ { "name": "vault_delete_span", "title": "Delete Span", - "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the deletion are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", + "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- After the deletion, every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", "inputSchema": { "type": "object", "properties": { @@ -243,7 +243,7 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to delete just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to delete just the single line containing start_anchor.", "type": "string", "minLength": 1 }, @@ -272,12 +272,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -335,7 +335,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", "inputSchema": { "type": "object", "properties": { @@ -360,7 +360,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when the file lists new entries at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. Designed for reconciliation consumers that know a boundary date and need deterministic chronological coverage without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" — section was provided without file; pass both or just file\n- \"on_or_after requires a file\" — on_or_after needs a file to scope the filter\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" — a dot-prefixed name would be a hidden file; memory files are always visible notes\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. It gives complete coverage from a known date without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would be a hidden file; memory files are always visible notes)\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", "inputSchema": { "type": "object", "properties": { @@ -395,7 +395,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF) readable via vault_read_file; !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image file is still cheap to read.\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF) readable via vault_read_file. When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image is still cheap to read.\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -423,7 +423,7 @@ { "name": "vault_insert_at_anchor", "title": "Insert at Anchor", - "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the content is inserted as whole lines before or after it (never splits a line).\n- position: \"before\" inserts above the anchor line; \"after\" inserts below it.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n- first_match: when the anchor matches multiple lines, takes the first instead of erroring.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", + "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the insert never splits a line.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -448,7 +448,7 @@ "content": { "type": "string", "minLength": 1, - "description": "Content to insert (one or more lines), inserted verbatim as whole lines — blank lines are kept, and a trailing newline adds a blank line after the block." + "description": "Content to insert (one or more lines)." }, "first_match": { "default": false, @@ -477,17 +477,18 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nParameters:\n- folder: folder path filter (e.g. \"attachments\" or \"Projects/media\"), searched recursively; omit for the whole vault. This tool reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n- extensions: restrict to these extensions — case-insensitive, with or without the leading dot (\".png\" and \"png\" both work)\n- limit: maximum entries returned (default 50). extension_counts and total always reflect the full filtered set, not just the returned page.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Files of supported types are readable via vault_read_file.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: Reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Only supported types are readable via vault_read_file.", "inputSchema": { "type": "object", "properties": { "folder": { - "description": "Folder path to search recursively (e.g. \"attachments\"). Omit to list the whole vault.", + "description": "Folder path to search recursively (e.g. \"attachments\" or \"Projects/media\"). Omit to list the whole vault.", "type": "string", "minLength": 1 }, "extensions": { "description": "Only include these extensions, case-insensitive, leading dot optional (e.g. [\".png\", \"jpg\"]).", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -517,7 +518,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with their H1/H2 heading structure, per-section entry counts, and each file's leading callout (by convention a \"Scope of this file\" block describing what belongs in it). An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy. leading_callout is the file's top-of-file callout ({ type, title, body }), or null.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -631,7 +632,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -650,13 +651,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -814,6 +815,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -873,7 +875,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -910,7 +912,7 @@ { "name": "vault_memory_recall", "title": "Memory Recall", - "description": "Recall memory entries about a topic — entry-granular hybrid (keyword + semantic) retrieval across ALL About Me/ files and ALL time. Returns every relevant dated entry sorted oldest-first, so the full evolution of a preference, opinion, or fact is visible — semantic matching finds early entries even when their phrasing differs from the query. Tuned for recall over precision: expect some marginal entries and judge relevance yourself when synthesizing an answer. Content-word queries (\"testing philosophy\", \"sustainable pacing\") rank best; a meta-framed query (\"opinions on testing\") whose relevance cut would come back empty degrades to relaxed any-term keyword matching instead of returning nothing.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"opinions on testing\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory or vault_delete_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. search_mode is \"hybrid\" when vector matching contributed, \"fts\" when the entries came from keyword matching alone — including the any-term fallback that rescues a would-be-empty result; reranked is true when the cross-encoder relevance cut was applied.", + "description": "Recall memory entries about a topic — entry-granular hybrid (keyword + semantic) retrieval across ALL About Me/ files and ALL time. Returns every relevant dated entry sorted oldest-first, so the full evolution of a preference, opinion, or fact is visible — semantic matching finds early entries even when their phrasing differs from the query. Tuned for recall over precision: expect some marginal entries and judge relevance yourself when synthesizing an answer. Content-word queries (\"testing philosophy\", \"sustainable pacing\") rank best. A meta-framed query (\"opinions on testing\") that scores below the relevance threshold degrades to relaxed any-term keyword matching instead of returning nothing.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"testing philosophy\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory or vault_delete_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. file is applied before limit, so limit keeps the most relevant matches from that file. search_mode is \"hybrid\" when vector matching contributed, \"fts\" when the entries came from keyword matching alone — including the any-term fallback that rescues a would-be-empty result; reranked is true when the cross-encoder relevance cut was applied.", "inputSchema": { "type": "object", "properties": { @@ -926,7 +928,7 @@ }, "limit": { "default": 50, - "description": "Cap on returned entries (default 50). When more match, the least-relevant are dropped and truncated=true — never a date range.", + "description": "Cap on returned entries (default 50).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -950,7 +952,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — old_path or new_path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use vault-relative paths outside hidden folders (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted only after the destination and every backlink are written. If a backlink write failed, new_path exists and old_path is intact: delete the partial new_path, then re-run the move. If the final delete failed, both paths exist: delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed, nothing was written. If a later link write failed, the note already lives at new_path: fix the remaining links in place (the error names the note whose update failed) instead of re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted last, after the destination and every backlink are written. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed: nothing was written. If a later link write failed: the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -989,7 +991,7 @@ { "name": "vault_patch_note", "title": "Patch Note", - "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim — don't begin content with the target heading (it's rejected to avoid a duplicate). No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, that content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included in the parent section. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", + "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim. No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, the pre-existing content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF), so it includes its child headings. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", "inputSchema": { "type": "object", "properties": { @@ -1006,15 +1008,15 @@ "replace", "insert_before" ], - "description": "append | prepend | replace | insert_before. replace and insert_before require a heading; append and prepend work with or without one." + "description": "append | prepend | replace | insert_before." }, "content": { "type": "string", "minLength": 1, - "description": "Markdown content to insert, written verbatim with no separator added — end it with a newline to leave a blank line after the inserted block. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." + "description": "Markdown content to insert. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." }, "heading": { - "description": "Target heading text (case-sensitive exact match). Required for replace and insert_before. Optional for append/prepend (omit for file-level operation).", + "description": "Target heading text (case-sensitive exact match). Omit for a file-level append or prepend.", "type": "string", "minLength": 1 }, @@ -1049,7 +1051,7 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\", raw: true }) — the canvas's exact JSON source\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\nExample: vault_read_file({ path: \"papers/research.pdf\", raw: true }) — each page rendered as an image block\n\nWhat each type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. Set raw: true for the exact JSON source instead (geometry, ids, colors — full fidelity).\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved. Set raw: true for page images instead — each page rendered and returned as an image block, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n- Line paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhat each file type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n\nraw: true switches a canvas or PDF to its other form:\n- Canvas: the exact JSON source (geometry, ids, colors — full fidelity) instead of the outline.\n- PDF: each page rendered and returned as an image block instead of extracted text, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n\nLine paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\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 (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON, so no outline can be built; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- an image that cannot be decoded (corrupt, empty, or not an image despite its extension) fails with the decoder's message, e.g. \"Input buffer contains unsupported image format\"; replace or re-export the file\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", "inputSchema": { "type": "object", "properties": { @@ -1093,7 +1095,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. Prefer vault_get_memory for About Me/ files (returns content without properties). To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. Prefer vault_get_memory for About Me/ files (returns content without properties). To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", "inputSchema": { "type": "object", "properties": { @@ -1111,7 +1113,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -1122,13 +1124,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1188,7 +1190,7 @@ { "name": "vault_replace_in_note", "title": "Replace in Note", - "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text is matched in the body only — frontmatter properties are never searched. Include enough surrounding context to ensure uniqueness when the target text appears in multiple places.\n- old_text + new_text together determine the operation: a non-empty new_text is an edit; an empty new_text (\"\") is a deletion. No regex — exact text only.\n- replace_all_occurrences (default false) replaces only the first match — a safety default when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\n- \"oldText cannot be empty\" — old_text must be at least one character\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", + "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text: include enough surrounding context to ensure uniqueness when the target text appears in multiple places. No regex — exact text only.\n- new_text: after a deletion (new_text=\"\"), every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n- replace_all_occurrences: replacing only the first match is a safety default for when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\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; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", "inputSchema": { "type": "object", "properties": { @@ -1232,7 +1234,7 @@ { "name": "vault_replace_span", "title": "Replace Span", - "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and the entire line from start to end is replaced (never cuts mid-line). Omit end_anchor for a single-line replace.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content replaces the entire matched span and must be non-empty. A trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the replacement are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", + "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range (never cuts mid-line).\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content: a trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently.\n- After the replacement, every run of consecutive blank lines in the note's body — including runs within the replacement text — is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -1247,14 +1249,14 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to replace just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to replace just the single line containing start_anchor.", "type": "string", "minLength": 1 }, "content": { "type": "string", "minLength": 1, - "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty; use vault_delete_span to delete without replacement." + "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty." }, "first_match": { "default": false, @@ -1282,17 +1284,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -1344,7 +1346,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -1396,14 +1398,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -1425,7 +1427,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -1436,7 +1438,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -1511,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "inputSchema": { "type": "object", "properties": { @@ -1544,7 +1546,7 @@ { "name": "vault_update_memory", "title": "Update Memory", - "description": "Append a dated entry to a section of a About Me/ memory file. The server prefixes the date automatically (\"- **YYYY-MM-DD**: entry text\") and inserts newest-first by default. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note). A new section name that is nearly identical to an existing heading (an HTML-entity slip, typo, or spacing variation) is rejected instead of created, so a mistyped name cannot silently fragment the file — names differing only in digits (e.g. \"2025\" vs \"2026\") are treated as distinct.\n\nParameters:\n- options.date — ISO YYYY-MM-DD, defaults to today (server timezone).\n- options.position — \"top\" (default, newest-first) inserts above existing entries; \"bottom\" appends below them.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"refusing memory write: … would shrink content\" — safety guard for diverged on-disk content. Re-read with vault_get_memory before retrying.\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry/section contains a control character\" — entry or section includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" — a dot-prefixed name would create a hidden file (invisible in Obsidian and to every listing); choose a visible name.\n- \"section not created: … is nearly identical to existing section …\" — near-duplicate guard; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section.\n\nReturns: Confirmation message (notes when an identical entry already existed and nothing was written).", + "description": "Append a dated entry to a section of a memory file in About Me/. The server prefixes the date automatically (\"- **YYYY-MM-DD**: entry text\") and inserts newest-first by default. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note). A new section name nearly identical to an existing heading (an HTML-entity slip, typo, or spacing variation) is rejected, so a mistyped name cannot silently fragment the file — names differing only in digits (e.g. \"2025\" vs \"2026\") are distinct.\n\nParameters:\n- options.position — \"top\" (default, newest-first) inserts above existing entries; \"bottom\" appends below them.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"refusing memory write: … would shrink content\" — safety guard: the write would leave the file at under half its size, which happens when the file holds content a rewrite cannot keep (for example long YAML comments in its properties). A retry returns the same refusal until a manual edit removes that content; inspect the file with vault_read_note to find it.\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — use a bare file name: no folder or slash, and no leading dot (that would create a hidden file, invisible in Obsidian and to every listing).\n- \"section not created: … is nearly identical to existing section …\" — near-duplicate guard; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section.\n\nReturns: Confirmation message (notes when an identical entry already existed and nothing was written).", "inputSchema": { "type": "object", "properties": { @@ -1568,7 +1570,7 @@ "type": "object", "properties": { "date": { - "description": "ISO YYYY-MM-DD date (defaults to today)", + "description": "ISO YYYY-MM-DD date (defaults to today, server timezone)", "type": "string", "minLength": 1 }, @@ -1604,7 +1606,7 @@ { "name": "vault_update_properties", "title": "Update Properties", - "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\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; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1620,7 +1622,7 @@ "minLength": 1 }, "additionalProperties": {}, - "description": "Properties to merge. New keys are added; existing keys are overwritten; a null value deletes that key; unmentioned keys are preserved." + "description": "Properties to merge; a null value deletes that key." } }, "required": [ @@ -1642,7 +1644,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\", scheduled: null }) — change the description, set one date, and clear another\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Clearing is always explicit null — omitting a field leaves it untouched.\n- status manages the checkbox and the done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁 delete / [onCompletion:: delete]): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day. When set together with status \"done\", the new rule governs the spawn. Setting recurrence to null while completing removes the rule and completes without spawning.\n- on_completion: passed together with status, the submitted value governs the delete decision — \"keep\" while completing a \"delete\" task prevents the deletion.\n- position: applies to a heading move or an auto-done-lane move. Without a heading, it triggers a same-lane reorder to the given position; omitting position performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n\nErrors:\n- \"note not found\" — path does not exist\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- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox \"[c]\" is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or add_subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence for each: a stored line that parses back differently than submitted (see Obsidian syntax above), a duplicate tag removed from the line, or a completed recurring task whose rule yields no next occurrence (unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { @@ -1663,7 +1665,7 @@ "maximum": 9007199254740991 }, "status": { - "description": "Target status. \"done\" appends the ✅ date and, on a Kanban board, moves the card and its checklist sub-items to the done lane (sub-item checkboxes are left as they are); a task with 🏁 delete / [onCompletion:: delete] is removed from the file instead. \"cancelled\" appends the ❌ date.", + "description": "Target status. \"done\" appends the ✅ date; \"cancelled\" appends the ❌ date.", "type": "string", "enum": [ "todo", @@ -1691,7 +1693,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", "anyOf": [ { "type": "string", @@ -1818,7 +1820,7 @@ "minLength": 1 }, "position": { - "description": "Where within the target heading the task lands after a heading move or auto-done-lane move. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.", + "description": "Where within the target heading the task lands. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to \"top\" on heading moves.", "anyOf": [ { "type": "string", @@ -1861,7 +1863,7 @@ { "name": "vault_write_note", "title": "Write Note", - "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content — this is a full write, not a partial edit. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved).\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\nPrefer vault_update_memory for appending dated entries to About Me/ memory files.\n\nLimitation: Writes the entire body. Do not use for surgical edits to large files — existing content will be lost unless you include it in the body parameter.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content: existing content will be lost unless you include it in body, so do not use this tool for surgical edits to large files. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved); overwriting without properties keeps the existing property values.\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\nPrefer vault_update_memory for appending dated entries to About Me/ memory files.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"cannot write note …: that path is not a file\" — a folder already has this name; choose another path\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; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1875,7 +1877,7 @@ "description": "Markdown body content — do not include frontmatter fences (---); use the properties parameter instead." }, "properties": { - "description": "Optional properties to merge. New keys are added; existing keys with matching names are overwritten; a null value deletes that key; unmentioned keys are preserved from the existing file.", + "description": "Optional properties to merge; a null value deletes that key.", "type": "object", "propertyNames": { "type": "string", 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 8f0f18cb4..1d8b6ec76 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 @@ -8,7 +8,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- heading is required on Kanban boards (notes with kanban-plugin frontmatter).\n- parent_block_id / parent_line: the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead of \"bottom\". Ignored when no heading or when placing under a parent.\n- priority: the plugin ranks \"no signifier\" (normal priority) between medium and low.\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day.\n- due / scheduled / start: omit a date rather than guessing — an absent 📅 means \"no deadline\".\n\nErrors:\n- \"note not found\" — path does not exist\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- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox \"[c]\" is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -28,23 +28,23 @@ "description": "The ^block-id for stable identification — letters, digits, and hyphens only ([a-zA-Z0-9-]+). Must be unique within the note." }, "heading": { - "description": "Target heading. Required on Kanban boards; optional on regular notes (omit to append at end of body).", + "description": "Target heading. On a regular note, omit to append at end of body.", "type": "string", "minLength": 1 }, "parent_block_id": { - "description": "^block-id (without the ^) of an existing task to nest under as a sub-task. Mutually exclusive with parent_line and heading.", + "description": "^block-id (without the ^) of an existing task to nest under as a sub-task.", "type": "string", "minLength": 1 }, "parent_line": { - "description": "1-based line number of an existing task to nest under as a sub-task. Mutually exclusive with parent_block_id and heading. Fragile if the file changed since the line was read.", + "description": "1-based line number of an existing task to nest under as a sub-task. Fragile if the file changed since the line was read.", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "position": { - "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to bottom. Kanban boards with new-card-insertion-method set to prepend default to top instead. Ignored when no heading or when placing under a parent.", + "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to bottom.", "anyOf": [ { "type": "string", @@ -72,7 +72,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", "type": "string", "minLength": 1 }, @@ -151,7 +151,7 @@ { "name": "vault_delete_memory", "title": "Delete Memory Entry", - "description": "Delete a single dated entry from a About Me/ memory file. Both date and entry text are required for exact matching — ensures only the intended entry is removed.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"AI tooling & memory (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: when a preference or fact has since evolved, append the new state via vault_update_memory (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_update_memory to supersede a changed entry; prefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together uniquely identify the bullet line within the given section. If multiple entries share the same date and text, deletion fails as ambiguous.\n- section scopes the match — an identical entry under a different heading is not found. Section matching is case-insensitive, with or without the \"(newest first)\" suffix.\n\nErrors:\n- \"memory file must not start with a dot\" — a dot-prefixed name would target a hidden file; memory files are always visible notes.\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section (e.g. from hand edits, sync conflicts, or entries predating duplicate protection; vault_update_memory refuses to write exact duplicates). Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content before retrying.\n\nReturns: Confirmation message.", + "description": "Delete a single dated entry from a memory file in About Me/. Both date and entry text are required for exact matching — ensures only the intended entry is removed.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"AI tooling & memory (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together uniquely identify the bullet line within the given section. If multiple entries share the same date and text, deletion fails as ambiguous.\n- section scopes the match — an identical entry under a different heading is not found. Section matching is case-insensitive, with or without the \"(newest first)\" suffix.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would target a hidden file; memory files are always visible notes).\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section (e.g. from hand edits, sync conflicts, or entries predating duplicate protection; vault_update_memory refuses to write exact duplicates). Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -173,7 +173,7 @@ "entry": { "type": "string", "minLength": 1, - "description": "Exact entry text as shown by vault_get_memory — without the \"- **YYYY-MM-DD**: \" prefix or bullet. Both date and entry must match for deletion." + "description": "Exact entry text as shown by vault_get_memory — without the \"- **YYYY-MM-DD**: \" prefix or bullet." } }, "required": [ @@ -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\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\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- \"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 rather than risk skipping a configured .trash/; 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- 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.", "inputSchema": { "type": "object", "properties": { @@ -230,7 +230,7 @@ { "name": "vault_delete_span", "title": "Delete Span", - "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the deletion are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", + "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- After the deletion, every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", "inputSchema": { "type": "object", "properties": { @@ -245,7 +245,7 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to delete just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to delete just the single line containing start_anchor.", "type": "string", "minLength": 1 }, @@ -274,12 +274,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -337,7 +337,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", "inputSchema": { "type": "object", "properties": { @@ -362,7 +362,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when the file lists new entries at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. Designed for reconciliation consumers that know a boundary date and need deterministic chronological coverage without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" — section was provided without file; pass both or just file\n- \"on_or_after requires a file\" — on_or_after needs a file to scope the filter\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" — a dot-prefixed name would be a hidden file; memory files are always visible notes\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. It gives complete coverage from a known date without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would be a hidden file; memory files are always visible notes)\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", "inputSchema": { "type": "object", "properties": { @@ -397,7 +397,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF) readable via vault_read_file; !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image file is still cheap to read.\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF) readable via vault_read_file. When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image is still cheap to read.\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -425,7 +425,7 @@ { "name": "vault_insert_at_anchor", "title": "Insert at Anchor", - "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\n Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the content is inserted as whole lines before or after it (never splits a line).\n- position: \"before\" inserts above the anchor line; \"after\" inserts below it.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n- first_match: when the anchor matches multiple lines, takes the first instead of erroring.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", + "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\n Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the insert never splits a line.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -450,7 +450,7 @@ "content": { "type": "string", "minLength": 1, - "description": "Content to insert (one or more lines), inserted verbatim as whole lines — blank lines are kept, and a trailing newline adds a blank line after the block." + "description": "Content to insert (one or more lines)." }, "first_match": { "default": false, @@ -479,17 +479,18 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nParameters:\n- folder: folder path filter (e.g. \"attachments\" or \"Projects/media\"), searched recursively; omit for the whole vault. This tool reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n- extensions: restrict to these extensions — case-insensitive, with or without the leading dot (\".png\" and \"png\" both work)\n- limit: maximum entries returned (default 50). extension_counts and total always reflect the full filtered set, not just the returned page.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Files of supported types are readable via vault_read_file.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: Reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Only supported types are readable via vault_read_file.", "inputSchema": { "type": "object", "properties": { "folder": { - "description": "Folder path to search recursively (e.g. \"attachments\"). Omit to list the whole vault.", + "description": "Folder path to search recursively (e.g. \"attachments\" or \"Projects/media\"). Omit to list the whole vault.", "type": "string", "minLength": 1 }, "extensions": { "description": "Only include these extensions, case-insensitive, leading dot optional (e.g. [\".png\", \"jpg\"]).", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -519,7 +520,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with their H1/H2 heading structure, per-section entry counts, and each file's leading callout (by convention a \"Scope of this file\" block describing what belongs in it). An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy. leading_callout is the file's top-of-file callout ({ type, title, body }), or null.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -633,7 +634,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -652,13 +653,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -816,6 +817,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -875,7 +877,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -912,7 +914,7 @@ { "name": "vault_memory_recall", "title": "Memory Recall", - "description": "Recall memory entries about a topic — entry-granular hybrid (keyword + semantic) retrieval across ALL About Me/ files and ALL time. Returns every relevant dated entry sorted oldest-first, so the full evolution of a preference, opinion, or fact is visible — semantic matching finds early entries even when their phrasing differs from the query. Tuned for recall over precision: expect some marginal entries and judge relevance yourself when synthesizing an answer. Content-word queries (\"testing philosophy\", \"sustainable pacing\") rank best; a meta-framed query (\"opinions on testing\") whose relevance cut would come back empty degrades to relaxed any-term keyword matching instead of returning nothing.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"opinions on testing\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory or vault_delete_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. search_mode is \"hybrid\" when vector matching contributed, \"fts\" when the entries came from keyword matching alone — including the any-term fallback that rescues a would-be-empty result; reranked is true when the cross-encoder relevance cut was applied.", + "description": "Recall memory entries about a topic — entry-granular hybrid (keyword + semantic) retrieval across ALL About Me/ files and ALL time. Returns every relevant dated entry sorted oldest-first, so the full evolution of a preference, opinion, or fact is visible — semantic matching finds early entries even when their phrasing differs from the query. Tuned for recall over precision: expect some marginal entries and judge relevance yourself when synthesizing an answer. Content-word queries (\"testing philosophy\", \"sustainable pacing\") rank best. A meta-framed query (\"opinions on testing\") that scores below the relevance threshold degrades to relaxed any-term keyword matching instead of returning nothing.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"testing philosophy\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory or vault_delete_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. file is applied before limit, so limit keeps the most relevant matches from that file. search_mode is \"hybrid\" when vector matching contributed, \"fts\" when the entries came from keyword matching alone — including the any-term fallback that rescues a would-be-empty result; reranked is true when the cross-encoder relevance cut was applied.", "inputSchema": { "type": "object", "properties": { @@ -928,7 +930,7 @@ }, "limit": { "default": 50, - "description": "Cap on returned entries (default 50). When more match, the least-relevant are dropped and truncated=true — never a date range.", + "description": "Cap on returned entries (default 50).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -952,7 +954,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — old_path or new_path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use vault-relative paths outside hidden folders (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted only after the destination and every backlink are written. If a backlink write failed, new_path exists and old_path is intact: delete the partial new_path, then re-run the move. If the final delete failed, both paths exist: delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed, nothing was written. If a later link write failed, the note already lives at new_path: fix the remaining links in place (the error names the note whose update failed) instead of re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted last, after the destination and every backlink are written. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed: nothing was written. If a later link write failed: the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -991,7 +993,7 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\", raw: true }) — the canvas's exact JSON source\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\nExample: vault_read_file({ path: \"papers/research.pdf\", raw: true }) — each page rendered as an image block\n\nWhat each type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. Set raw: true for the exact JSON source instead (geometry, ids, colors — full fidelity).\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved. Set raw: true for page images instead — each page rendered and returned as an image block, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n- Line paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhat each file type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n\nraw: true switches a canvas or PDF to its other form:\n- Canvas: the exact JSON source (geometry, ids, colors — full fidelity) instead of the outline.\n- PDF: each page rendered and returned as an image block instead of extracted text, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n\nLine paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\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 (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON, so no outline can be built; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- an image that cannot be decoded (corrupt, empty, or not an image despite its extension) fails with the decoder's message, e.g. \"Input buffer contains unsupported image format\"; replace or re-export the file\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", "inputSchema": { "type": "object", "properties": { @@ -1035,7 +1037,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. Prefer vault_get_memory for About Me/ files (returns content without properties). To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. Prefer vault_get_memory for About Me/ files (returns content without properties). To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", "inputSchema": { "type": "object", "properties": { @@ -1053,7 +1055,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -1064,13 +1066,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1130,7 +1132,7 @@ { "name": "vault_replace_in_note", "title": "Replace in Note", - "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span.\n\nParameters:\n- old_text is matched in the body only — frontmatter properties are never searched. Include enough surrounding context to ensure uniqueness when the target text appears in multiple places.\n- old_text + new_text together determine the operation: a non-empty new_text is an edit; an empty new_text (\"\") is a deletion. No regex — exact text only.\n- replace_all_occurrences (default false) replaces only the first match — a safety default when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\n- \"oldText cannot be empty\" — old_text must be at least one character\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", + "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span.\n\nParameters:\n- old_text: include enough surrounding context to ensure uniqueness when the target text appears in multiple places. No regex — exact text only.\n- new_text: after a deletion (new_text=\"\"), every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n- replace_all_occurrences: replacing only the first match is a safety default for when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\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; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", "inputSchema": { "type": "object", "properties": { @@ -1174,7 +1176,7 @@ { "name": "vault_replace_span", "title": "Replace Span", - "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and the entire line from start to end is replaced (never cuts mid-line). Omit end_anchor for a single-line replace.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content replaces the entire matched span and must be non-empty. A trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the replacement are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", + "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range (never cuts mid-line).\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content: a trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently.\n- After the replacement, every run of consecutive blank lines in the note's body — including runs within the replacement text — is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -1189,14 +1191,14 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to replace just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to replace just the single line containing start_anchor.", "type": "string", "minLength": 1 }, "content": { "type": "string", "minLength": 1, - "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty; use vault_delete_span to delete without replacement." + "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty." }, "first_match": { "default": false, @@ -1224,17 +1226,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -1286,7 +1288,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -1338,14 +1340,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -1367,7 +1369,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -1378,7 +1380,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -1453,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "inputSchema": { "type": "object", "properties": { @@ -1486,7 +1488,7 @@ { "name": "vault_update_memory", "title": "Update Memory", - "description": "Append a dated entry to a section of a About Me/ memory file. The server prefixes the date automatically (\"- **YYYY-MM-DD**: entry text\") and inserts newest-first by default. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note). A new section name that is nearly identical to an existing heading (an HTML-entity slip, typo, or spacing variation) is rejected instead of created, so a mistyped name cannot silently fragment the file — names differing only in digits (e.g. \"2025\" vs \"2026\") are treated as distinct.\n\nParameters:\n- options.date — ISO YYYY-MM-DD, defaults to today (server timezone).\n- options.position — \"top\" (default, newest-first) inserts above existing entries; \"bottom\" appends below them.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"refusing memory write: … would shrink content\" — safety guard for diverged on-disk content. Re-read with vault_get_memory before retrying.\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry/section contains a control character\" — entry or section includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" — a dot-prefixed name would create a hidden file (invisible in Obsidian and to every listing); choose a visible name.\n- \"section not created: … is nearly identical to existing section …\" — near-duplicate guard; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section.\n\nReturns: Confirmation message (notes when an identical entry already existed and nothing was written).", + "description": "Append a dated entry to a section of a memory file in About Me/. The server prefixes the date automatically (\"- **YYYY-MM-DD**: entry text\") and inserts newest-first by default. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note). A new section name nearly identical to an existing heading (an HTML-entity slip, typo, or spacing variation) is rejected, so a mistyped name cannot silently fragment the file — names differing only in digits (e.g. \"2025\" vs \"2026\") are distinct.\n\nParameters:\n- options.position — \"top\" (default, newest-first) inserts above existing entries; \"bottom\" appends below them.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"refusing memory write: … would shrink content\" — safety guard: the write would leave the file at under half its size, which happens when the file holds content a rewrite cannot keep (for example long YAML comments in its properties). A retry returns the same refusal until a manual edit removes that content; inspect the file with vault_read_note to find it.\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — use a bare file name: no folder or slash, and no leading dot (that would create a hidden file, invisible in Obsidian and to every listing).\n- \"section not created: … is nearly identical to existing section …\" — near-duplicate guard; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section.\n\nReturns: Confirmation message (notes when an identical entry already existed and nothing was written).", "inputSchema": { "type": "object", "properties": { @@ -1510,7 +1512,7 @@ "type": "object", "properties": { "date": { - "description": "ISO YYYY-MM-DD date (defaults to today)", + "description": "ISO YYYY-MM-DD date (defaults to today, server timezone)", "type": "string", "minLength": 1 }, @@ -1546,7 +1548,7 @@ { "name": "vault_update_properties", "title": "Update Properties", - "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\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; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1562,7 +1564,7 @@ "minLength": 1 }, "additionalProperties": {}, - "description": "Properties to merge. New keys are added; existing keys are overwritten; a null value deletes that key; unmentioned keys are preserved." + "description": "Properties to merge; a null value deletes that key." } }, "required": [ @@ -1584,7 +1586,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\", scheduled: null }) — change the description, set one date, and clear another\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Clearing is always explicit null — omitting a field leaves it untouched.\n- status manages the checkbox and the done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁 delete / [onCompletion:: delete]): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day. When set together with status \"done\", the new rule governs the spawn. Setting recurrence to null while completing removes the rule and completes without spawning.\n- on_completion: passed together with status, the submitted value governs the delete decision — \"keep\" while completing a \"delete\" task prevents the deletion.\n- position: applies to a heading move or an auto-done-lane move. Without a heading, it triggers a same-lane reorder to the given position; omitting position performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n\nErrors:\n- \"note not found\" — path does not exist\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- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox \"[c]\" is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or add_subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence for each: a stored line that parses back differently than submitted (see Obsidian syntax above), a duplicate tag removed from the line, or a completed recurring task whose rule yields no next occurrence (unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { @@ -1605,7 +1607,7 @@ "maximum": 9007199254740991 }, "status": { - "description": "Target status. \"done\" appends the ✅ date and, on a Kanban board, moves the card and its checklist sub-items to the done lane (sub-item checkboxes are left as they are); a task with 🏁 delete / [onCompletion:: delete] is removed from the file instead. \"cancelled\" appends the ❌ date.", + "description": "Target status. \"done\" appends the ✅ date; \"cancelled\" appends the ❌ date.", "type": "string", "enum": [ "todo", @@ -1633,7 +1635,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", "anyOf": [ { "type": "string", @@ -1760,7 +1762,7 @@ "minLength": 1 }, "position": { - "description": "Where within the target heading the task lands after a heading move or auto-done-lane move. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.", + "description": "Where within the target heading the task lands. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to \"top\" on heading moves.", "anyOf": [ { "type": "string", @@ -1803,7 +1805,7 @@ { "name": "vault_write_note", "title": "Write Note", - "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content — this is a full write, not a partial edit. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved).\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\nPrefer vault_update_memory for appending dated entries to About Me/ memory files.\n\nLimitation: Writes the entire body. Do not use for surgical edits to large files — existing content will be lost unless you include it in the body parameter.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content: existing content will be lost unless you include it in body, so do not use this tool for surgical edits to large files. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved); overwriting without properties keeps the existing property values.\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\nPrefer vault_update_memory for appending dated entries to About Me/ memory files.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"cannot write note …: that path is not a file\" — a folder already has this name; choose another path\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; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1817,7 +1819,7 @@ "description": "Markdown body content — do not include frontmatter fences (---); use the properties parameter instead." }, "properties": { - "description": "Optional properties to merge. New keys are added; existing keys with matching names are overwritten; a null value deletes that key; unmentioned keys are preserved from the existing file.", + "description": "Optional properties to merge; a null value deletes that key.", "type": "object", "propertyNames": { "type": "string", 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 d7697d19f..38ad2c2e1 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 @@ -8,7 +8,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- heading is required on Kanban boards (notes with kanban-plugin frontmatter).\n- parent_block_id / parent_line: the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead of \"bottom\". Ignored when no heading or when placing under a parent.\n- priority: the plugin ranks \"no signifier\" (normal priority) between medium and low.\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day.\n- due / scheduled / start: omit a date rather than guessing — an absent 📅 means \"no deadline\".\n\nErrors:\n- \"note not found\" — path does not exist\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- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox \"[c]\" is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -28,23 +28,23 @@ "description": "The ^block-id for stable identification — letters, digits, and hyphens only ([a-zA-Z0-9-]+). Must be unique within the note." }, "heading": { - "description": "Target heading. Required on Kanban boards; optional on regular notes (omit to append at end of body).", + "description": "Target heading. On a regular note, omit to append at end of body.", "type": "string", "minLength": 1 }, "parent_block_id": { - "description": "^block-id (without the ^) of an existing task to nest under as a sub-task. Mutually exclusive with parent_line and heading.", + "description": "^block-id (without the ^) of an existing task to nest under as a sub-task.", "type": "string", "minLength": 1 }, "parent_line": { - "description": "1-based line number of an existing task to nest under as a sub-task. Mutually exclusive with parent_block_id and heading. Fragile if the file changed since the line was read.", + "description": "1-based line number of an existing task to nest under as a sub-task. Fragile if the file changed since the line was read.", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "position": { - "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to bottom. Kanban boards with new-card-insertion-method set to prepend default to top instead. Ignored when no heading or when placing under a parent.", + "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to bottom.", "anyOf": [ { "type": "string", @@ -72,7 +72,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", "type": "string", "minLength": 1 }, @@ -151,7 +151,7 @@ { "name": "vault_delete_memory", "title": "Delete Memory Entry", - "description": "Delete a single dated entry from a About Me/ memory file. Both date and entry text are required for exact matching — ensures only the intended entry is removed.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"AI tooling & memory (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: when a preference or fact has since evolved, append the new state via vault_update_memory (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_update_memory to supersede a changed entry; prefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together uniquely identify the bullet line within the given section. If multiple entries share the same date and text, deletion fails as ambiguous.\n- section scopes the match — an identical entry under a different heading is not found. Section matching is case-insensitive, with or without the \"(newest first)\" suffix.\n\nErrors:\n- \"memory file must not start with a dot\" — a dot-prefixed name would target a hidden file; memory files are always visible notes.\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section (e.g. from hand edits, sync conflicts, or entries predating duplicate protection; vault_update_memory refuses to write exact duplicates). Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content before retrying.\n\nReturns: Confirmation message.", + "description": "Delete a single dated entry from a memory file in About Me/. Both date and entry text are required for exact matching — ensures only the intended entry is removed.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"AI tooling & memory (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together uniquely identify the bullet line within the given section. If multiple entries share the same date and text, deletion fails as ambiguous.\n- section scopes the match — an identical entry under a different heading is not found. Section matching is case-insensitive, with or without the \"(newest first)\" suffix.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would target a hidden file; memory files are always visible notes).\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section (e.g. from hand edits, sync conflicts, or entries predating duplicate protection; vault_update_memory refuses to write exact duplicates). Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -173,7 +173,7 @@ "entry": { "type": "string", "minLength": 1, - "description": "Exact entry text as shown by vault_get_memory — without the \"- **YYYY-MM-DD**: \" prefix or bullet. Both date and entry must match for deletion." + "description": "Exact entry text as shown by vault_get_memory — without the \"- **YYYY-MM-DD**: \" prefix or bullet." } }, "required": [ @@ -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\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\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- \"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 rather than risk skipping a configured .trash/; 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- 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.", "inputSchema": { "type": "object", "properties": { @@ -230,7 +230,7 @@ { "name": "vault_delete_span", "title": "Delete Span", - "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the deletion are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", + "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- After the deletion, every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", "inputSchema": { "type": "object", "properties": { @@ -245,7 +245,7 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to delete just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to delete just the single line containing start_anchor.", "type": "string", "minLength": 1 }, @@ -274,12 +274,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -337,7 +337,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", "inputSchema": { "type": "object", "properties": { @@ -362,7 +362,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when the file lists new entries at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. Designed for reconciliation consumers that know a boundary date and need deterministic chronological coverage without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" — section was provided without file; pass both or just file\n- \"on_or_after requires a file\" — on_or_after needs a file to scope the filter\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" — a dot-prefixed name would be a hidden file; memory files are always visible notes\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. It gives complete coverage from a known date without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would be a hidden file; memory files are always visible notes)\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", "inputSchema": { "type": "object", "properties": { @@ -397,7 +397,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF) readable via vault_read_file; !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image file is still cheap to read.\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF) readable via vault_read_file. When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image is still cheap to read.\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -425,7 +425,7 @@ { "name": "vault_insert_at_anchor", "title": "Insert at Anchor", - "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the content is inserted as whole lines before or after it (never splits a line).\n- position: \"before\" inserts above the anchor line; \"after\" inserts below it.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n- first_match: when the anchor matches multiple lines, takes the first instead of erroring.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", + "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the insert never splits a line.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -450,7 +450,7 @@ "content": { "type": "string", "minLength": 1, - "description": "Content to insert (one or more lines), inserted verbatim as whole lines — blank lines are kept, and a trailing newline adds a blank line after the block." + "description": "Content to insert (one or more lines)." }, "first_match": { "default": false, @@ -479,17 +479,18 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nParameters:\n- folder: folder path filter (e.g. \"attachments\" or \"Projects/media\"), searched recursively; omit for the whole vault. This tool reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n- extensions: restrict to these extensions — case-insensitive, with or without the leading dot (\".png\" and \"png\" both work)\n- limit: maximum entries returned (default 50). extension_counts and total always reflect the full filtered set, not just the returned page.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Files of supported types are readable via vault_read_file.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: Reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Only supported types are readable via vault_read_file.", "inputSchema": { "type": "object", "properties": { "folder": { - "description": "Folder path to search recursively (e.g. \"attachments\"). Omit to list the whole vault.", + "description": "Folder path to search recursively (e.g. \"attachments\" or \"Projects/media\"). Omit to list the whole vault.", "type": "string", "minLength": 1 }, "extensions": { "description": "Only include these extensions, case-insensitive, leading dot optional (e.g. [\".png\", \"jpg\"]).", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -519,7 +520,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with their H1/H2 heading structure, per-section entry counts, and each file's leading callout (by convention a \"Scope of this file\" block describing what belongs in it). An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy. leading_callout is the file's top-of-file callout ({ type, title, body }), or null.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -633,7 +634,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -652,13 +653,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -816,6 +817,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -875,7 +877,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -912,7 +914,7 @@ { "name": "vault_memory_recall", "title": "Memory Recall", - "description": "Recall memory entries about a topic — entry-granular keyword retrieval across ALL About Me/ files and ALL time. Returns every matching dated entry sorted oldest-first, so the evolution of a preference, opinion, or fact reads in order. Matching is stemmed keywords only (semantic matching is off — EMBEDDING_ENABLED=false), and phrasing drifts across months, so re-query with synonyms to cover a topic fully (e.g. \"pacing\", then \"recovery\", then \"sustainable hours\"). A multi-word query whose terms never co-occur in one entry degrades to any-term matching before returning empty.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"opinions on testing\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory or vault_delete_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range. search_mode is always \"fts\" and reranked always false in keyword-only mode.", + "description": "Recall memory entries about a topic — entry-granular keyword retrieval across ALL About Me/ files and ALL time. Returns every matching dated entry sorted oldest-first, so the evolution of a preference, opinion, or fact reads in order. Matching is stemmed keywords only (semantic matching is off — EMBEDDING_ENABLED=false), and phrasing drifts across months, so re-query with synonyms to cover a topic fully (e.g. \"pacing\", then \"recovery\", then \"sustainable hours\"). A multi-word query whose terms never co-occur in one entry degrades to any-term matching before returning empty.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"testing philosophy\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory or vault_delete_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. file is applied before limit, so limit keeps the most relevant matches from that file. search_mode is always \"fts\" and reranked always false in keyword-only mode.", "inputSchema": { "type": "object", "properties": { @@ -928,7 +930,7 @@ }, "limit": { "default": 50, - "description": "Cap on returned entries (default 50). When more match, the least-relevant are dropped and truncated=true — never a date range.", + "description": "Cap on returned entries (default 50).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -952,7 +954,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — old_path or new_path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use vault-relative paths outside hidden folders (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted only after the destination and every backlink are written. If a backlink write failed, new_path exists and old_path is intact: delete the partial new_path, then re-run the move. If the final delete failed, both paths exist: delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed, nothing was written. If a later link write failed, the note already lives at new_path: fix the remaining links in place (the error names the note whose update failed) instead of re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted last, after the destination and every backlink are written. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed: nothing was written. If a later link write failed: the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -991,7 +993,7 @@ { "name": "vault_patch_note", "title": "Patch Note", - "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim — don't begin content with the target heading (it's rejected to avoid a duplicate). No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, that content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included in the parent section. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", + "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim. No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, the pre-existing content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF), so it includes its child headings. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", "inputSchema": { "type": "object", "properties": { @@ -1008,15 +1010,15 @@ "replace", "insert_before" ], - "description": "append | prepend | replace | insert_before. replace and insert_before require a heading; append and prepend work with or without one." + "description": "append | prepend | replace | insert_before." }, "content": { "type": "string", "minLength": 1, - "description": "Markdown content to insert, written verbatim with no separator added — end it with a newline to leave a blank line after the inserted block. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." + "description": "Markdown content to insert. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." }, "heading": { - "description": "Target heading text (case-sensitive exact match). Required for replace and insert_before. Optional for append/prepend (omit for file-level operation).", + "description": "Target heading text (case-sensitive exact match). Omit for a file-level append or prepend.", "type": "string", "minLength": 1 }, @@ -1051,7 +1053,7 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\", raw: true }) — the canvas's exact JSON source\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\nExample: vault_read_file({ path: \"papers/research.pdf\", raw: true }) — each page rendered as an image block\n\nWhat each type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. Set raw: true for the exact JSON source instead (geometry, ids, colors — full fidelity).\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved. Set raw: true for page images instead — each page rendered and returned as an image block, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n- Line paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhat each file type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n\nraw: true switches a canvas or PDF to its other form:\n- Canvas: the exact JSON source (geometry, ids, colors — full fidelity) instead of the outline.\n- PDF: each page rendered and returned as an image block instead of extracted text, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n\nLine paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\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 (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON, so no outline can be built; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- an image that cannot be decoded (corrupt, empty, or not an image despite its extension) fails with the decoder's message, e.g. \"Input buffer contains unsupported image format\"; replace or re-export the file\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", "inputSchema": { "type": "object", "properties": { @@ -1095,7 +1097,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. Prefer vault_get_memory for About Me/ files (returns content without properties). To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. Prefer vault_get_memory for About Me/ files (returns content without properties). To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", "inputSchema": { "type": "object", "properties": { @@ -1113,7 +1115,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -1124,13 +1126,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1190,7 +1192,7 @@ { "name": "vault_replace_in_note", "title": "Replace in Note", - "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text is matched in the body only — frontmatter properties are never searched. Include enough surrounding context to ensure uniqueness when the target text appears in multiple places.\n- old_text + new_text together determine the operation: a non-empty new_text is an edit; an empty new_text (\"\") is a deletion. No regex — exact text only.\n- replace_all_occurrences (default false) replaces only the first match — a safety default when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\n- \"oldText cannot be empty\" — old_text must be at least one character\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", + "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text: include enough surrounding context to ensure uniqueness when the target text appears in multiple places. No regex — exact text only.\n- new_text: after a deletion (new_text=\"\"), every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n- replace_all_occurrences: replacing only the first match is a safety default for when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\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; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", "inputSchema": { "type": "object", "properties": { @@ -1234,7 +1236,7 @@ { "name": "vault_replace_span", "title": "Replace Span", - "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and the entire line from start to end is replaced (never cuts mid-line). Omit end_anchor for a single-line replace.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content replaces the entire matched span and must be non-empty. A trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the replacement are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", + "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range (never cuts mid-line).\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content: a trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently.\n- After the replacement, every run of consecutive blank lines in the note's body — including runs within the replacement text — is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -1249,14 +1251,14 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to replace just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to replace just the single line containing start_anchor.", "type": "string", "minLength": 1 }, "content": { "type": "string", "minLength": 1, - "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty; use vault_delete_span to delete without replacement." + "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty." }, "first_match": { "default": false, @@ -1284,17 +1286,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -1346,7 +1348,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -1398,14 +1400,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -1427,7 +1429,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -1438,7 +1440,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -1513,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "inputSchema": { "type": "object", "properties": { @@ -1546,7 +1548,7 @@ { "name": "vault_update_memory", "title": "Update Memory", - "description": "Append a dated entry to a section of a About Me/ memory file. The server prefixes the date automatically (\"- **YYYY-MM-DD**: entry text\") and inserts newest-first by default. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note). A new section name that is nearly identical to an existing heading (an HTML-entity slip, typo, or spacing variation) is rejected instead of created, so a mistyped name cannot silently fragment the file — names differing only in digits (e.g. \"2025\" vs \"2026\") are treated as distinct.\n\nParameters:\n- options.date — ISO YYYY-MM-DD, defaults to today (server timezone).\n- options.position — \"top\" (default, newest-first) inserts above existing entries; \"bottom\" appends below them.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"refusing memory write: … would shrink content\" — safety guard for diverged on-disk content. Re-read with vault_get_memory before retrying.\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry/section contains a control character\" — entry or section includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" — a dot-prefixed name would create a hidden file (invisible in Obsidian and to every listing); choose a visible name.\n- \"section not created: … is nearly identical to existing section …\" — near-duplicate guard; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section.\n\nReturns: Confirmation message (notes when an identical entry already existed and nothing was written).", + "description": "Append a dated entry to a section of a memory file in About Me/. The server prefixes the date automatically (\"- **YYYY-MM-DD**: entry text\") and inserts newest-first by default. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note). A new section name nearly identical to an existing heading (an HTML-entity slip, typo, or spacing variation) is rejected, so a mistyped name cannot silently fragment the file — names differing only in digits (e.g. \"2025\" vs \"2026\") are distinct.\n\nParameters:\n- options.position — \"top\" (default, newest-first) inserts above existing entries; \"bottom\" appends below them.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"refusing memory write: … would shrink content\" — safety guard: the write would leave the file at under half its size, which happens when the file holds content a rewrite cannot keep (for example long YAML comments in its properties). A retry returns the same refusal until a manual edit removes that content; inspect the file with vault_read_note to find it.\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — use a bare file name: no folder or slash, and no leading dot (that would create a hidden file, invisible in Obsidian and to every listing).\n- \"section not created: … is nearly identical to existing section …\" — near-duplicate guard; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section.\n\nReturns: Confirmation message (notes when an identical entry already existed and nothing was written).", "inputSchema": { "type": "object", "properties": { @@ -1570,7 +1572,7 @@ "type": "object", "properties": { "date": { - "description": "ISO YYYY-MM-DD date (defaults to today)", + "description": "ISO YYYY-MM-DD date (defaults to today, server timezone)", "type": "string", "minLength": 1 }, @@ -1606,7 +1608,7 @@ { "name": "vault_update_properties", "title": "Update Properties", - "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\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; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1622,7 +1624,7 @@ "minLength": 1 }, "additionalProperties": {}, - "description": "Properties to merge. New keys are added; existing keys are overwritten; a null value deletes that key; unmentioned keys are preserved." + "description": "Properties to merge; a null value deletes that key." } }, "required": [ @@ -1644,7 +1646,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\", scheduled: null }) — change the description, set one date, and clear another\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Clearing is always explicit null — omitting a field leaves it untouched.\n- status manages the checkbox and the done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁 delete / [onCompletion:: delete]): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day. When set together with status \"done\", the new rule governs the spawn. Setting recurrence to null while completing removes the rule and completes without spawning.\n- on_completion: passed together with status, the submitted value governs the delete decision — \"keep\" while completing a \"delete\" task prevents the deletion.\n- position: applies to a heading move or an auto-done-lane move. Without a heading, it triggers a same-lane reorder to the given position; omitting position performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n\nErrors:\n- \"note not found\" — path does not exist\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- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox \"[c]\" is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or add_subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence for each: a stored line that parses back differently than submitted (see Obsidian syntax above), a duplicate tag removed from the line, or a completed recurring task whose rule yields no next occurrence (unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { @@ -1665,7 +1667,7 @@ "maximum": 9007199254740991 }, "status": { - "description": "Target status. \"done\" appends the ✅ date and, on a Kanban board, moves the card and its checklist sub-items to the done lane (sub-item checkboxes are left as they are); a task with 🏁 delete / [onCompletion:: delete] is removed from the file instead. \"cancelled\" appends the ❌ date.", + "description": "Target status. \"done\" appends the ✅ date; \"cancelled\" appends the ❌ date.", "type": "string", "enum": [ "todo", @@ -1693,7 +1695,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", "anyOf": [ { "type": "string", @@ -1820,7 +1822,7 @@ "minLength": 1 }, "position": { - "description": "Where within the target heading the task lands after a heading move or auto-done-lane move. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.", + "description": "Where within the target heading the task lands. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to \"top\" on heading moves.", "anyOf": [ { "type": "string", @@ -1863,7 +1865,7 @@ { "name": "vault_write_note", "title": "Write Note", - "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content — this is a full write, not a partial edit. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved).\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\nPrefer vault_update_memory for appending dated entries to About Me/ memory files.\n\nLimitation: Writes the entire body. Do not use for surgical edits to large files — existing content will be lost unless you include it in the body parameter.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content: existing content will be lost unless you include it in body, so do not use this tool for surgical edits to large files. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved); overwriting without properties keeps the existing property values.\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\nPrefer vault_update_memory for appending dated entries to About Me/ memory files.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"cannot write note …: that path is not a file\" — a folder already has this name; choose another path\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; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1877,7 +1879,7 @@ "description": "Markdown body content — do not include frontmatter fences (---); use the properties parameter instead." }, "properties": { - "description": "Optional properties to merge. New keys are added; existing keys with matching names are overwritten; a null value deletes that key; unmentioned keys are preserved from the existing file.", + "description": "Optional properties to merge; a null value deletes that key.", "type": "object", "propertyNames": { "type": "string", 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 20e6bbd16..60bbb956c 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 @@ -9,7 +9,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- heading is required on Kanban boards (notes with kanban-plugin frontmatter).\n- parent_block_id / parent_line: the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead of \"bottom\". Ignored when no heading or when placing under a parent.\n- priority: the plugin ranks \"no signifier\" (normal priority) between medium and low.\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day.\n- due / scheduled / start: omit a date rather than guessing — an absent 📅 means \"no deadline\".\n\nErrors:\n- \"note not found\" — path does not exist\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- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox \"[c]\" is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -29,23 +29,23 @@ "description": "The ^block-id for stable identification — letters, digits, and hyphens only ([a-zA-Z0-9-]+). Must be unique within the note." }, "heading": { - "description": "Target heading. Required on Kanban boards; optional on regular notes (omit to append at end of body).", + "description": "Target heading. On a regular note, omit to append at end of body.", "type": "string", "minLength": 1 }, "parent_block_id": { - "description": "^block-id (without the ^) of an existing task to nest under as a sub-task. Mutually exclusive with parent_line and heading.", + "description": "^block-id (without the ^) of an existing task to nest under as a sub-task.", "type": "string", "minLength": 1 }, "parent_line": { - "description": "1-based line number of an existing task to nest under as a sub-task. Mutually exclusive with parent_block_id and heading. Fragile if the file changed since the line was read.", + "description": "1-based line number of an existing task to nest under as a sub-task. Fragile if the file changed since the line was read.", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "position": { - "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to bottom. Kanban boards with new-card-insertion-method set to prepend default to top instead. Ignored when no heading or when placing under a parent.", + "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to bottom.", "anyOf": [ { "type": "string", @@ -73,7 +73,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", "type": "string", "minLength": 1 }, @@ -152,7 +152,7 @@ { "name": "vault_delete_memory", "title": "Delete Memory Entry", - "description": "Delete a single dated entry from a About Me/ memory file. Both date and entry text are required for exact matching — ensures only the intended entry is removed.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"AI tooling & memory (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: when a preference or fact has since evolved, append the new state via vault_update_memory (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_update_memory to supersede a changed entry; prefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together uniquely identify the bullet line within the given section. If multiple entries share the same date and text, deletion fails as ambiguous.\n- section scopes the match — an identical entry under a different heading is not found. Section matching is case-insensitive, with or without the \"(newest first)\" suffix.\n\nErrors:\n- \"memory file must not start with a dot\" — a dot-prefixed name would target a hidden file; memory files are always visible notes.\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section (e.g. from hand edits, sync conflicts, or entries predating duplicate protection; vault_update_memory refuses to write exact duplicates). Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content before retrying.\n\nReturns: Confirmation message.", + "description": "Delete a single dated entry from a memory file in About Me/. Both date and entry text are required for exact matching — ensures only the intended entry is removed.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"AI tooling & memory (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together uniquely identify the bullet line within the given section. If multiple entries share the same date and text, deletion fails as ambiguous.\n- section scopes the match — an identical entry under a different heading is not found. Section matching is case-insensitive, with or without the \"(newest first)\" suffix.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would target a hidden file; memory files are always visible notes).\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section (e.g. from hand edits, sync conflicts, or entries predating duplicate protection; vault_update_memory refuses to write exact duplicates). Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -174,7 +174,7 @@ "entry": { "type": "string", "minLength": 1, - "description": "Exact entry text as shown by vault_get_memory — without the \"- **YYYY-MM-DD**: \" prefix or bullet. Both date and entry must match for deletion." + "description": "Exact entry text as shown by vault_get_memory — without the \"- **YYYY-MM-DD**: \" prefix or bullet." } }, "required": [ @@ -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\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\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- \"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 rather than risk skipping a configured .trash/; 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- 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.", "inputSchema": { "type": "object", "properties": { @@ -231,7 +231,7 @@ { "name": "vault_delete_span", "title": "Delete Span", - "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the deletion are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", + "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- After the deletion, every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", "inputSchema": { "type": "object", "properties": { @@ -246,7 +246,7 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to delete just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to delete just the single line containing start_anchor.", "type": "string", "minLength": 1 }, @@ -275,12 +275,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -338,7 +338,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", "inputSchema": { "type": "object", "properties": { @@ -363,7 +363,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when the file lists new entries at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. Designed for reconciliation consumers that know a boundary date and need deterministic chronological coverage without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" — section was provided without file; pass both or just file\n- \"on_or_after requires a file\" — on_or_after needs a file to scope the filter\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" — a dot-prefixed name would be a hidden file; memory files are always visible notes\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. It gives complete coverage from a known date without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would be a hidden file; memory files are always visible notes)\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", "inputSchema": { "type": "object", "properties": { @@ -398,7 +398,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF); !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links).\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF). When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -426,7 +426,7 @@ { "name": "vault_insert_at_anchor", "title": "Insert at Anchor", - "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the content is inserted as whole lines before or after it (never splits a line).\n- position: \"before\" inserts above the anchor line; \"after\" inserts below it.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n- first_match: when the anchor matches multiple lines, takes the first instead of erroring.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", + "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the insert never splits a line.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -451,7 +451,7 @@ "content": { "type": "string", "minLength": 1, - "description": "Content to insert (one or more lines), inserted verbatim as whole lines — blank lines are kept, and a trailing newline adds a blank line after the block." + "description": "Content to insert (one or more lines)." }, "first_match": { "default": false, @@ -480,7 +480,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with their H1/H2 heading structure, per-section entry counts, and each file's leading callout (by convention a \"Scope of this file\" block describing what belongs in it). An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy. leading_callout is the file's top-of-file callout ({ type, title, body }), or null.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -594,7 +594,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -613,13 +613,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -777,6 +777,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -836,7 +837,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -873,7 +874,7 @@ { "name": "vault_memory_recall", "title": "Memory Recall", - "description": "Recall memory entries about a topic — entry-granular keyword retrieval across ALL About Me/ files and ALL time. Returns every matching dated entry sorted oldest-first, so the evolution of a preference, opinion, or fact reads in order. Matching is stemmed keywords only (semantic matching is off — EMBEDDING_ENABLED=false), and phrasing drifts across months, so re-query with synonyms to cover a topic fully (e.g. \"pacing\", then \"recovery\", then \"sustainable hours\"). A multi-word query whose terms never co-occur in one entry degrades to any-term matching before returning empty.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"opinions on testing\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory or vault_delete_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range. search_mode is always \"fts\" and reranked always false in keyword-only mode.", + "description": "Recall memory entries about a topic — entry-granular keyword retrieval across ALL About Me/ files and ALL time. Returns every matching dated entry sorted oldest-first, so the evolution of a preference, opinion, or fact reads in order. Matching is stemmed keywords only (semantic matching is off — EMBEDDING_ENABLED=false), and phrasing drifts across months, so re-query with synonyms to cover a topic fully (e.g. \"pacing\", then \"recovery\", then \"sustainable hours\"). A multi-word query whose terms never co-occur in one entry degrades to any-term matching before returning empty.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"testing philosophy\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory or vault_delete_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. file is applied before limit, so limit keeps the most relevant matches from that file. search_mode is always \"fts\" and reranked always false in keyword-only mode.", "inputSchema": { "type": "object", "properties": { @@ -889,7 +890,7 @@ }, "limit": { "default": 50, - "description": "Cap on returned entries (default 50). When more match, the least-relevant are dropped and truncated=true — never a date range.", + "description": "Cap on returned entries (default 50).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -913,7 +914,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — old_path or new_path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use vault-relative paths outside hidden folders (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted only after the destination and every backlink are written. If a backlink write failed, new_path exists and old_path is intact: delete the partial new_path, then re-run the move. If the final delete failed, both paths exist: delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed, nothing was written. If a later link write failed, the note already lives at new_path: fix the remaining links in place (the error names the note whose update failed) instead of re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted last, after the destination and every backlink are written. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed: nothing was written. If a later link write failed: the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -952,7 +953,7 @@ { "name": "vault_patch_note", "title": "Patch Note", - "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim — don't begin content with the target heading (it's rejected to avoid a duplicate). No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, that content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included in the parent section. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", + "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim. No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, the pre-existing content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF), so it includes its child headings. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", "inputSchema": { "type": "object", "properties": { @@ -969,15 +970,15 @@ "replace", "insert_before" ], - "description": "append | prepend | replace | insert_before. replace and insert_before require a heading; append and prepend work with or without one." + "description": "append | prepend | replace | insert_before." }, "content": { "type": "string", "minLength": 1, - "description": "Markdown content to insert, written verbatim with no separator added — end it with a newline to leave a blank line after the inserted block. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." + "description": "Markdown content to insert. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." }, "heading": { - "description": "Target heading text (case-sensitive exact match). Required for replace and insert_before. Optional for append/prepend (omit for file-level operation).", + "description": "Target heading text (case-sensitive exact match). Omit for a file-level append or prepend.", "type": "string", "minLength": 1 }, @@ -1012,7 +1013,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. Prefer vault_get_memory for About Me/ files (returns content without properties). To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. Prefer vault_get_memory for About Me/ files (returns content without properties). To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", "inputSchema": { "type": "object", "properties": { @@ -1030,7 +1031,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -1041,13 +1042,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1107,7 +1108,7 @@ { "name": "vault_replace_in_note", "title": "Replace in Note", - "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text is matched in the body only — frontmatter properties are never searched. Include enough surrounding context to ensure uniqueness when the target text appears in multiple places.\n- old_text + new_text together determine the operation: a non-empty new_text is an edit; an empty new_text (\"\") is a deletion. No regex — exact text only.\n- replace_all_occurrences (default false) replaces only the first match — a safety default when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\n- \"oldText cannot be empty\" — old_text must be at least one character\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", + "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text: include enough surrounding context to ensure uniqueness when the target text appears in multiple places. No regex — exact text only.\n- new_text: after a deletion (new_text=\"\"), every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n- replace_all_occurrences: replacing only the first match is a safety default for when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\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; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", "inputSchema": { "type": "object", "properties": { @@ -1151,7 +1152,7 @@ { "name": "vault_replace_span", "title": "Replace Span", - "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and the entire line from start to end is replaced (never cuts mid-line). Omit end_anchor for a single-line replace.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content replaces the entire matched span and must be non-empty. A trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the replacement are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", + "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range (never cuts mid-line).\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content: a trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently.\n- After the replacement, every run of consecutive blank lines in the note's body — including runs within the replacement text — is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -1166,14 +1167,14 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to replace just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to replace just the single line containing start_anchor.", "type": "string", "minLength": 1 }, "content": { "type": "string", "minLength": 1, - "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty; use vault_delete_span to delete without replacement." + "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty." }, "first_match": { "default": false, @@ -1201,17 +1202,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -1263,7 +1264,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -1315,14 +1316,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -1344,7 +1345,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -1355,7 +1356,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -1430,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "inputSchema": { "type": "object", "properties": { @@ -1463,7 +1464,7 @@ { "name": "vault_update_memory", "title": "Update Memory", - "description": "Append a dated entry to a section of a About Me/ memory file. The server prefixes the date automatically (\"- **YYYY-MM-DD**: entry text\") and inserts newest-first by default. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note). A new section name that is nearly identical to an existing heading (an HTML-entity slip, typo, or spacing variation) is rejected instead of created, so a mistyped name cannot silently fragment the file — names differing only in digits (e.g. \"2025\" vs \"2026\") are treated as distinct.\n\nParameters:\n- options.date — ISO YYYY-MM-DD, defaults to today (server timezone).\n- options.position — \"top\" (default, newest-first) inserts above existing entries; \"bottom\" appends below them.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"refusing memory write: … would shrink content\" — safety guard for diverged on-disk content. Re-read with vault_get_memory before retrying.\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry/section contains a control character\" — entry or section includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" — a dot-prefixed name would create a hidden file (invisible in Obsidian and to every listing); choose a visible name.\n- \"section not created: … is nearly identical to existing section …\" — near-duplicate guard; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section.\n\nReturns: Confirmation message (notes when an identical entry already existed and nothing was written).", + "description": "Append a dated entry to a section of a memory file in About Me/. The server prefixes the date automatically (\"- **YYYY-MM-DD**: entry text\") and inserts newest-first by default. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note). A new section name nearly identical to an existing heading (an HTML-entity slip, typo, or spacing variation) is rejected, so a mistyped name cannot silently fragment the file — names differing only in digits (e.g. \"2025\" vs \"2026\") are distinct.\n\nParameters:\n- options.position — \"top\" (default, newest-first) inserts above existing entries; \"bottom\" appends below them.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"refusing memory write: … would shrink content\" — safety guard: the write would leave the file at under half its size, which happens when the file holds content a rewrite cannot keep (for example long YAML comments in its properties). A retry returns the same refusal until a manual edit removes that content; inspect the file with vault_read_note to find it.\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — use a bare file name: no folder or slash, and no leading dot (that would create a hidden file, invisible in Obsidian and to every listing).\n- \"section not created: … is nearly identical to existing section …\" — near-duplicate guard; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section.\n\nReturns: Confirmation message (notes when an identical entry already existed and nothing was written).", "inputSchema": { "type": "object", "properties": { @@ -1487,7 +1488,7 @@ "type": "object", "properties": { "date": { - "description": "ISO YYYY-MM-DD date (defaults to today)", + "description": "ISO YYYY-MM-DD date (defaults to today, server timezone)", "type": "string", "minLength": 1 }, @@ -1523,7 +1524,7 @@ { "name": "vault_update_properties", "title": "Update Properties", - "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\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; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1539,7 +1540,7 @@ "minLength": 1 }, "additionalProperties": {}, - "description": "Properties to merge. New keys are added; existing keys are overwritten; a null value deletes that key; unmentioned keys are preserved." + "description": "Properties to merge; a null value deletes that key." } }, "required": [ @@ -1561,7 +1562,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\", scheduled: null }) — change the description, set one date, and clear another\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Clearing is always explicit null — omitting a field leaves it untouched.\n- status manages the checkbox and the done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁 delete / [onCompletion:: delete]): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day. When set together with status \"done\", the new rule governs the spawn. Setting recurrence to null while completing removes the rule and completes without spawning.\n- on_completion: passed together with status, the submitted value governs the delete decision — \"keep\" while completing a \"delete\" task prevents the deletion.\n- position: applies to a heading move or an auto-done-lane move. Without a heading, it triggers a same-lane reorder to the given position; omitting position performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n\nErrors:\n- \"note not found\" — path does not exist\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- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox \"[c]\" is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or add_subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence for each: a stored line that parses back differently than submitted (see Obsidian syntax above), a duplicate tag removed from the line, or a completed recurring task whose rule yields no next occurrence (unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { @@ -1582,7 +1583,7 @@ "maximum": 9007199254740991 }, "status": { - "description": "Target status. \"done\" appends the ✅ date and, on a Kanban board, moves the card and its checklist sub-items to the done lane (sub-item checkboxes are left as they are); a task with 🏁 delete / [onCompletion:: delete] is removed from the file instead. \"cancelled\" appends the ❌ date.", + "description": "Target status. \"done\" appends the ✅ date; \"cancelled\" appends the ❌ date.", "type": "string", "enum": [ "todo", @@ -1610,7 +1611,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", "anyOf": [ { "type": "string", @@ -1737,7 +1738,7 @@ "minLength": 1 }, "position": { - "description": "Where within the target heading the task lands after a heading move or auto-done-lane move. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.", + "description": "Where within the target heading the task lands. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to \"top\" on heading moves.", "anyOf": [ { "type": "string", @@ -1780,7 +1781,7 @@ { "name": "vault_write_note", "title": "Write Note", - "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content — this is a full write, not a partial edit. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved).\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\nPrefer vault_update_memory for appending dated entries to About Me/ memory files.\n\nLimitation: Writes the entire body. Do not use for surgical edits to large files — existing content will be lost unless you include it in the body parameter.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content: existing content will be lost unless you include it in body, so do not use this tool for surgical edits to large files. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved); overwriting without properties keeps the existing property values.\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\nPrefer vault_update_memory for appending dated entries to About Me/ memory files.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"cannot write note …: that path is not a file\" — a folder already has this name; choose another path\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; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1794,7 +1795,7 @@ "description": "Markdown body content — do not include frontmatter fences (---); use the properties parameter instead." }, "properties": { - "description": "Optional properties to merge. New keys are added; existing keys with matching names are overwritten; a null value deletes that key; unmentioned keys are preserved from the existing file.", + "description": "Optional properties to merge; a null value deletes that key.", "type": "object", "propertyNames": { "type": "string", 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 c5961fd15..d4a72ea4c 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 @@ -8,7 +8,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- heading is required on Kanban boards (notes with kanban-plugin frontmatter).\n- parent_block_id / parent_line: the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead of \"bottom\". Ignored when no heading or when placing under a parent.\n- priority: the plugin ranks \"no signifier\" (normal priority) between medium and low.\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day.\n- due / scheduled / start: omit a date rather than guessing — an absent 📅 means \"no deadline\".\n\nErrors:\n- \"note not found\" — path does not exist\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- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox \"[c]\" is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -28,23 +28,23 @@ "description": "The ^block-id for stable identification — letters, digits, and hyphens only ([a-zA-Z0-9-]+). Must be unique within the note." }, "heading": { - "description": "Target heading. Required on Kanban boards; optional on regular notes (omit to append at end of body).", + "description": "Target heading. On a regular note, omit to append at end of body.", "type": "string", "minLength": 1 }, "parent_block_id": { - "description": "^block-id (without the ^) of an existing task to nest under as a sub-task. Mutually exclusive with parent_line and heading.", + "description": "^block-id (without the ^) of an existing task to nest under as a sub-task.", "type": "string", "minLength": 1 }, "parent_line": { - "description": "1-based line number of an existing task to nest under as a sub-task. Mutually exclusive with parent_block_id and heading. Fragile if the file changed since the line was read.", + "description": "1-based line number of an existing task to nest under as a sub-task. Fragile if the file changed since the line was read.", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "position": { - "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to bottom. Kanban boards with new-card-insertion-method set to prepend default to top instead. Ignored when no heading or when placing under a parent.", + "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to bottom.", "anyOf": [ { "type": "string", @@ -72,7 +72,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", "type": "string", "minLength": 1 }, @@ -151,7 +151,7 @@ { "name": "vault_delete_memory", "title": "Delete Memory Entry", - "description": "Delete a single dated entry from a About Me/ memory file. Both date and entry text are required for exact matching — ensures only the intended entry is removed.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"AI tooling & memory (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: when a preference or fact has since evolved, append the new state via vault_update_memory (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_update_memory to supersede a changed entry; prefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together uniquely identify the bullet line within the given section. If multiple entries share the same date and text, deletion fails as ambiguous.\n- section scopes the match — an identical entry under a different heading is not found. Section matching is case-insensitive, with or without the \"(newest first)\" suffix.\n\nErrors:\n- \"memory file must not start with a dot\" — a dot-prefixed name would target a hidden file; memory files are always visible notes.\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section (e.g. from hand edits, sync conflicts, or entries predating duplicate protection; vault_update_memory refuses to write exact duplicates). Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content before retrying.\n\nReturns: Confirmation message.", + "description": "Delete a single dated entry from a memory file in About Me/. Both date and entry text are required for exact matching — ensures only the intended entry is removed.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"AI tooling & memory (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together uniquely identify the bullet line within the given section. If multiple entries share the same date and text, deletion fails as ambiguous.\n- section scopes the match — an identical entry under a different heading is not found. Section matching is case-insensitive, with or without the \"(newest first)\" suffix.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would target a hidden file; memory files are always visible notes).\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section (e.g. from hand edits, sync conflicts, or entries predating duplicate protection; vault_update_memory refuses to write exact duplicates). Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -173,7 +173,7 @@ "entry": { "type": "string", "minLength": 1, - "description": "Exact entry text as shown by vault_get_memory — without the \"- **YYYY-MM-DD**: \" prefix or bullet. Both date and entry must match for deletion." + "description": "Exact entry text as shown by vault_get_memory — without the \"- **YYYY-MM-DD**: \" prefix or bullet." } }, "required": [ @@ -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\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\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- \"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 rather than risk skipping a configured .trash/; 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- 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.", "inputSchema": { "type": "object", "properties": { @@ -230,7 +230,7 @@ { "name": "vault_delete_span", "title": "Delete Span", - "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the deletion are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", + "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- After the deletion, every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", "inputSchema": { "type": "object", "properties": { @@ -245,7 +245,7 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to delete just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to delete just the single line containing start_anchor.", "type": "string", "minLength": 1 }, @@ -274,12 +274,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -337,7 +337,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", "inputSchema": { "type": "object", "properties": { @@ -362,7 +362,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when the file lists new entries at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. Designed for reconciliation consumers that know a boundary date and need deterministic chronological coverage without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" — section was provided without file; pass both or just file\n- \"on_or_after requires a file\" — on_or_after needs a file to scope the filter\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" — a dot-prefixed name would be a hidden file; memory files are always visible notes\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. It gives complete coverage from a known date without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would be a hidden file; memory files are always visible notes)\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", "inputSchema": { "type": "object", "properties": { @@ -397,7 +397,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF); !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links).\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF). When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -425,7 +425,7 @@ { "name": "vault_insert_at_anchor", "title": "Insert at Anchor", - "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the content is inserted as whole lines before or after it (never splits a line).\n- position: \"before\" inserts above the anchor line; \"after\" inserts below it.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n- first_match: when the anchor matches multiple lines, takes the first instead of erroring.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", + "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the insert never splits a line.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -450,7 +450,7 @@ "content": { "type": "string", "minLength": 1, - "description": "Content to insert (one or more lines), inserted verbatim as whole lines — blank lines are kept, and a trailing newline adds a blank line after the block." + "description": "Content to insert (one or more lines)." }, "first_match": { "default": false, @@ -479,7 +479,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with their H1/H2 heading structure, per-section entry counts, and each file's leading callout (by convention a \"Scope of this file\" block describing what belongs in it). An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy. leading_callout is the file's top-of-file callout ({ type, title, body }), or null.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -593,7 +593,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -612,13 +612,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -776,6 +776,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -835,7 +836,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -872,7 +873,7 @@ { "name": "vault_memory_recall", "title": "Memory Recall", - "description": "Recall memory entries about a topic — entry-granular hybrid (keyword + semantic) retrieval across ALL About Me/ files and ALL time. Returns every relevant dated entry sorted oldest-first, so the full evolution of a preference, opinion, or fact is visible — semantic matching finds early entries even when their phrasing differs from the query. Tuned for recall over precision: expect some marginal entries and judge relevance yourself when synthesizing an answer. Content-word queries (\"testing philosophy\", \"sustainable pacing\") rank best; a meta-framed query (\"opinions on testing\") whose relevance cut would come back empty degrades to relaxed any-term keyword matching instead of returning nothing.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"opinions on testing\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory or vault_delete_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. search_mode is \"hybrid\" when vector matching contributed, \"fts\" when the entries came from keyword matching alone — including the any-term fallback that rescues a would-be-empty result; reranked is true when the cross-encoder relevance cut was applied.", + "description": "Recall memory entries about a topic — entry-granular hybrid (keyword + semantic) retrieval across ALL About Me/ files and ALL time. Returns every relevant dated entry sorted oldest-first, so the full evolution of a preference, opinion, or fact is visible — semantic matching finds early entries even when their phrasing differs from the query. Tuned for recall over precision: expect some marginal entries and judge relevance yourself when synthesizing an answer. Content-word queries (\"testing philosophy\", \"sustainable pacing\") rank best. A meta-framed query (\"opinions on testing\") that scores below the relevance threshold degrades to relaxed any-term keyword matching instead of returning nothing.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"testing philosophy\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory or vault_delete_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. file is applied before limit, so limit keeps the most relevant matches from that file. search_mode is \"hybrid\" when vector matching contributed, \"fts\" when the entries came from keyword matching alone — including the any-term fallback that rescues a would-be-empty result; reranked is true when the cross-encoder relevance cut was applied.", "inputSchema": { "type": "object", "properties": { @@ -888,7 +889,7 @@ }, "limit": { "default": 50, - "description": "Cap on returned entries (default 50). When more match, the least-relevant are dropped and truncated=true — never a date range.", + "description": "Cap on returned entries (default 50).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -912,7 +913,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — old_path or new_path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use vault-relative paths outside hidden folders (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted only after the destination and every backlink are written. If a backlink write failed, new_path exists and old_path is intact: delete the partial new_path, then re-run the move. If the final delete failed, both paths exist: delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed, nothing was written. If a later link write failed, the note already lives at new_path: fix the remaining links in place (the error names the note whose update failed) instead of re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted last, after the destination and every backlink are written. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed: nothing was written. If a later link write failed: the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -951,7 +952,7 @@ { "name": "vault_patch_note", "title": "Patch Note", - "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim — don't begin content with the target heading (it's rejected to avoid a duplicate). No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, that content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included in the parent section. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", + "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim. No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, the pre-existing content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF), so it includes its child headings. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", "inputSchema": { "type": "object", "properties": { @@ -968,15 +969,15 @@ "replace", "insert_before" ], - "description": "append | prepend | replace | insert_before. replace and insert_before require a heading; append and prepend work with or without one." + "description": "append | prepend | replace | insert_before." }, "content": { "type": "string", "minLength": 1, - "description": "Markdown content to insert, written verbatim with no separator added — end it with a newline to leave a blank line after the inserted block. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." + "description": "Markdown content to insert. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." }, "heading": { - "description": "Target heading text (case-sensitive exact match). Required for replace and insert_before. Optional for append/prepend (omit for file-level operation).", + "description": "Target heading text (case-sensitive exact match). Omit for a file-level append or prepend.", "type": "string", "minLength": 1 }, @@ -1011,7 +1012,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. Prefer vault_get_memory for About Me/ files (returns content without properties). To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. Prefer vault_get_memory for About Me/ files (returns content without properties). To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", "inputSchema": { "type": "object", "properties": { @@ -1029,7 +1030,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -1040,13 +1041,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1106,7 +1107,7 @@ { "name": "vault_replace_in_note", "title": "Replace in Note", - "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text is matched in the body only — frontmatter properties are never searched. Include enough surrounding context to ensure uniqueness when the target text appears in multiple places.\n- old_text + new_text together determine the operation: a non-empty new_text is an edit; an empty new_text (\"\") is a deletion. No regex — exact text only.\n- replace_all_occurrences (default false) replaces only the first match — a safety default when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\n- \"oldText cannot be empty\" — old_text must be at least one character\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", + "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text: include enough surrounding context to ensure uniqueness when the target text appears in multiple places. No regex — exact text only.\n- new_text: after a deletion (new_text=\"\"), every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n- replace_all_occurrences: replacing only the first match is a safety default for when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\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; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", "inputSchema": { "type": "object", "properties": { @@ -1150,7 +1151,7 @@ { "name": "vault_replace_span", "title": "Replace Span", - "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and the entire line from start to end is replaced (never cuts mid-line). Omit end_anchor for a single-line replace.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content replaces the entire matched span and must be non-empty. A trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the replacement are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", + "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range (never cuts mid-line).\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content: a trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently.\n- After the replacement, every run of consecutive blank lines in the note's body — including runs within the replacement text — is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -1165,14 +1166,14 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to replace just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to replace just the single line containing start_anchor.", "type": "string", "minLength": 1 }, "content": { "type": "string", "minLength": 1, - "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty; use vault_delete_span to delete without replacement." + "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty." }, "first_match": { "default": false, @@ -1200,17 +1201,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -1262,7 +1263,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -1314,14 +1315,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -1343,7 +1344,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -1354,7 +1355,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -1429,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "inputSchema": { "type": "object", "properties": { @@ -1462,7 +1463,7 @@ { "name": "vault_update_memory", "title": "Update Memory", - "description": "Append a dated entry to a section of a About Me/ memory file. The server prefixes the date automatically (\"- **YYYY-MM-DD**: entry text\") and inserts newest-first by default. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note). A new section name that is nearly identical to an existing heading (an HTML-entity slip, typo, or spacing variation) is rejected instead of created, so a mistyped name cannot silently fragment the file — names differing only in digits (e.g. \"2025\" vs \"2026\") are treated as distinct.\n\nParameters:\n- options.date — ISO YYYY-MM-DD, defaults to today (server timezone).\n- options.position — \"top\" (default, newest-first) inserts above existing entries; \"bottom\" appends below them.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"refusing memory write: … would shrink content\" — safety guard for diverged on-disk content. Re-read with vault_get_memory before retrying.\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry/section contains a control character\" — entry or section includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" — a dot-prefixed name would create a hidden file (invisible in Obsidian and to every listing); choose a visible name.\n- \"section not created: … is nearly identical to existing section …\" — near-duplicate guard; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section.\n\nReturns: Confirmation message (notes when an identical entry already existed and nothing was written).", + "description": "Append a dated entry to a section of a memory file in About Me/. The server prefixes the date automatically (\"- **YYYY-MM-DD**: entry text\") and inserts newest-first by default. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note). A new section name nearly identical to an existing heading (an HTML-entity slip, typo, or spacing variation) is rejected, so a mistyped name cannot silently fragment the file — names differing only in digits (e.g. \"2025\" vs \"2026\") are distinct.\n\nParameters:\n- options.position — \"top\" (default, newest-first) inserts above existing entries; \"bottom\" appends below them.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"refusing memory write: … would shrink content\" — safety guard: the write would leave the file at under half its size, which happens when the file holds content a rewrite cannot keep (for example long YAML comments in its properties). A retry returns the same refusal until a manual edit removes that content; inspect the file with vault_read_note to find it.\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — use a bare file name: no folder or slash, and no leading dot (that would create a hidden file, invisible in Obsidian and to every listing).\n- \"section not created: … is nearly identical to existing section …\" — near-duplicate guard; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section.\n\nReturns: Confirmation message (notes when an identical entry already existed and nothing was written).", "inputSchema": { "type": "object", "properties": { @@ -1486,7 +1487,7 @@ "type": "object", "properties": { "date": { - "description": "ISO YYYY-MM-DD date (defaults to today)", + "description": "ISO YYYY-MM-DD date (defaults to today, server timezone)", "type": "string", "minLength": 1 }, @@ -1522,7 +1523,7 @@ { "name": "vault_update_properties", "title": "Update Properties", - "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\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; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1538,7 +1539,7 @@ "minLength": 1 }, "additionalProperties": {}, - "description": "Properties to merge. New keys are added; existing keys are overwritten; a null value deletes that key; unmentioned keys are preserved." + "description": "Properties to merge; a null value deletes that key." } }, "required": [ @@ -1560,7 +1561,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\", scheduled: null }) — change the description, set one date, and clear another\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Clearing is always explicit null — omitting a field leaves it untouched.\n- status manages the checkbox and the done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁 delete / [onCompletion:: delete]): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day. When set together with status \"done\", the new rule governs the spawn. Setting recurrence to null while completing removes the rule and completes without spawning.\n- on_completion: passed together with status, the submitted value governs the delete decision — \"keep\" while completing a \"delete\" task prevents the deletion.\n- position: applies to a heading move or an auto-done-lane move. Without a heading, it triggers a same-lane reorder to the given position; omitting position performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n\nErrors:\n- \"note not found\" — path does not exist\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- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox \"[c]\" is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or add_subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence for each: a stored line that parses back differently than submitted (see Obsidian syntax above), a duplicate tag removed from the line, or a completed recurring task whose rule yields no next occurrence (unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { @@ -1581,7 +1582,7 @@ "maximum": 9007199254740991 }, "status": { - "description": "Target status. \"done\" appends the ✅ date and, on a Kanban board, moves the card and its checklist sub-items to the done lane (sub-item checkboxes are left as they are); a task with 🏁 delete / [onCompletion:: delete] is removed from the file instead. \"cancelled\" appends the ❌ date.", + "description": "Target status. \"done\" appends the ✅ date; \"cancelled\" appends the ❌ date.", "type": "string", "enum": [ "todo", @@ -1609,7 +1610,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", "anyOf": [ { "type": "string", @@ -1736,7 +1737,7 @@ "minLength": 1 }, "position": { - "description": "Where within the target heading the task lands after a heading move or auto-done-lane move. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.", + "description": "Where within the target heading the task lands. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to \"top\" on heading moves.", "anyOf": [ { "type": "string", @@ -1779,7 +1780,7 @@ { "name": "vault_write_note", "title": "Write Note", - "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content — this is a full write, not a partial edit. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved).\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\nPrefer vault_update_memory for appending dated entries to About Me/ memory files.\n\nLimitation: Writes the entire body. Do not use for surgical edits to large files — existing content will be lost unless you include it in the body parameter.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content: existing content will be lost unless you include it in body, so do not use this tool for surgical edits to large files. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved); overwriting without properties keeps the existing property values.\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\nPrefer vault_update_memory for appending dated entries to About Me/ memory files.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"cannot write note …: that path is not a file\" — a folder already has this name; choose another path\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; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1793,7 +1794,7 @@ "description": "Markdown body content — do not include frontmatter fences (---); use the properties parameter instead." }, "properties": { - "description": "Optional properties to merge. New keys are added; existing keys with matching names are overwritten; a null value deletes that key; unmentioned keys are preserved from the existing file.", + "description": "Optional properties to merge; a null value deletes that key.", "type": "object", "propertyNames": { "type": "string", 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 104a116fc..de28cda90 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 @@ -9,7 +9,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- heading is required on Kanban boards (notes with kanban-plugin frontmatter).\n- parent_block_id / parent_line: the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead of \"bottom\". Ignored when no heading or when placing under a parent.\n- priority: the plugin ranks \"no signifier\" (normal priority) between medium and low.\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day.\n- due / scheduled / start: omit a date rather than guessing — an absent 📅 means \"no deadline\".\n\nErrors:\n- \"note not found\" — path does not exist\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- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox \"[c]\" is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -29,23 +29,23 @@ "description": "The ^block-id for stable identification — letters, digits, and hyphens only ([a-zA-Z0-9-]+). Must be unique within the note." }, "heading": { - "description": "Target heading. Required on Kanban boards; optional on regular notes (omit to append at end of body).", + "description": "Target heading. On a regular note, omit to append at end of body.", "type": "string", "minLength": 1 }, "parent_block_id": { - "description": "^block-id (without the ^) of an existing task to nest under as a sub-task. Mutually exclusive with parent_line and heading.", + "description": "^block-id (without the ^) of an existing task to nest under as a sub-task.", "type": "string", "minLength": 1 }, "parent_line": { - "description": "1-based line number of an existing task to nest under as a sub-task. Mutually exclusive with parent_block_id and heading. Fragile if the file changed since the line was read.", + "description": "1-based line number of an existing task to nest under as a sub-task. Fragile if the file changed since the line was read.", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "position": { - "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to bottom. Kanban boards with new-card-insertion-method set to prepend default to top instead. Ignored when no heading or when placing under a parent.", + "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to bottom.", "anyOf": [ { "type": "string", @@ -73,7 +73,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", "type": "string", "minLength": 1 }, @@ -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\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\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- \"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 rather than risk skipping a configured .trash/; 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- 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.", "inputSchema": { "type": "object", "properties": { @@ -185,7 +185,7 @@ { "name": "vault_delete_span", "title": "Delete Span", - "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the deletion are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", + "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- After the deletion, every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", "inputSchema": { "type": "object", "properties": { @@ -200,7 +200,7 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to delete just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to delete just the single line containing start_anchor.", "type": "string", "minLength": 1 }, @@ -229,12 +229,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -292,7 +292,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", "inputSchema": { "type": "object", "properties": { @@ -317,7 +317,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF) readable via vault_read_file; !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image file is still cheap to read.\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF) readable via vault_read_file. When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image is still cheap to read.\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -345,7 +345,7 @@ { "name": "vault_insert_at_anchor", "title": "Insert at Anchor", - "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the content is inserted as whole lines before or after it (never splits a line).\n- position: \"before\" inserts above the anchor line; \"after\" inserts below it.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n- first_match: when the anchor matches multiple lines, takes the first instead of erroring.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", + "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the insert never splits a line.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -370,7 +370,7 @@ "content": { "type": "string", "minLength": 1, - "description": "Content to insert (one or more lines), inserted verbatim as whole lines — blank lines are kept, and a trailing newline adds a blank line after the block." + "description": "Content to insert (one or more lines)." }, "first_match": { "default": false, @@ -399,17 +399,18 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nParameters:\n- folder: folder path filter (e.g. \"attachments\" or \"Projects/media\"), searched recursively; omit for the whole vault. This tool reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n- extensions: restrict to these extensions — case-insensitive, with or without the leading dot (\".png\" and \"png\" both work)\n- limit: maximum entries returned (default 50). extension_counts and total always reflect the full filtered set, not just the returned page.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Files of supported types are readable via vault_read_file.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: Reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Only supported types are readable via vault_read_file.", "inputSchema": { "type": "object", "properties": { "folder": { - "description": "Folder path to search recursively (e.g. \"attachments\"). Omit to list the whole vault.", + "description": "Folder path to search recursively (e.g. \"attachments\" or \"Projects/media\"). Omit to list the whole vault.", "type": "string", "minLength": 1 }, "extensions": { "description": "Only include these extensions, case-insensitive, leading dot optional (e.g. [\".png\", \"jpg\"]).", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -534,7 +535,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -553,13 +554,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -717,6 +718,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -776,7 +778,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -813,7 +815,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — old_path or new_path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use vault-relative paths outside hidden folders (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted only after the destination and every backlink are written. If a backlink write failed, new_path exists and old_path is intact: delete the partial new_path, then re-run the move. If the final delete failed, both paths exist: delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed, nothing was written. If a later link write failed, the note already lives at new_path: fix the remaining links in place (the error names the note whose update failed) instead of re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted last, after the destination and every backlink are written. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed: nothing was written. If a later link write failed: the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -852,7 +854,7 @@ { "name": "vault_patch_note", "title": "Patch Note", - "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim — don't begin content with the target heading (it's rejected to avoid a duplicate). No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, that content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included in the parent section. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", + "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim. No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, the pre-existing content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF), so it includes its child headings. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", "inputSchema": { "type": "object", "properties": { @@ -869,15 +871,15 @@ "replace", "insert_before" ], - "description": "append | prepend | replace | insert_before. replace and insert_before require a heading; append and prepend work with or without one." + "description": "append | prepend | replace | insert_before." }, "content": { "type": "string", "minLength": 1, - "description": "Markdown content to insert, written verbatim with no separator added — end it with a newline to leave a blank line after the inserted block. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." + "description": "Markdown content to insert. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." }, "heading": { - "description": "Target heading text (case-sensitive exact match). Required for replace and insert_before. Optional for append/prepend (omit for file-level operation).", + "description": "Target heading text (case-sensitive exact match). Omit for a file-level append or prepend.", "type": "string", "minLength": 1 }, @@ -912,7 +914,7 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\", raw: true }) — the canvas's exact JSON source\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\nExample: vault_read_file({ path: \"papers/research.pdf\", raw: true }) — each page rendered as an image block\n\nWhat each type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. Set raw: true for the exact JSON source instead (geometry, ids, colors — full fidelity).\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved. Set raw: true for page images instead — each page rendered and returned as an image block, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n- Line paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhat each file type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n\nraw: true switches a canvas or PDF to its other form:\n- Canvas: the exact JSON source (geometry, ids, colors — full fidelity) instead of the outline.\n- PDF: each page rendered and returned as an image block instead of extracted text, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n\nLine paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\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 (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON, so no outline can be built; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- an image that cannot be decoded (corrupt, empty, or not an image despite its extension) fails with the decoder's message, e.g. \"Input buffer contains unsupported image format\"; replace or re-export the file\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", "inputSchema": { "type": "object", "properties": { @@ -956,7 +958,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", "inputSchema": { "type": "object", "properties": { @@ -974,7 +976,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -985,13 +987,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1051,7 +1053,7 @@ { "name": "vault_replace_in_note", "title": "Replace in Note", - "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text is matched in the body only — frontmatter properties are never searched. Include enough surrounding context to ensure uniqueness when the target text appears in multiple places.\n- old_text + new_text together determine the operation: a non-empty new_text is an edit; an empty new_text (\"\") is a deletion. No regex — exact text only.\n- replace_all_occurrences (default false) replaces only the first match — a safety default when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\n- \"oldText cannot be empty\" — old_text must be at least one character\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", + "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text: include enough surrounding context to ensure uniqueness when the target text appears in multiple places. No regex — exact text only.\n- new_text: after a deletion (new_text=\"\"), every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n- replace_all_occurrences: replacing only the first match is a safety default for when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\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; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", "inputSchema": { "type": "object", "properties": { @@ -1095,7 +1097,7 @@ { "name": "vault_replace_span", "title": "Replace Span", - "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and the entire line from start to end is replaced (never cuts mid-line). Omit end_anchor for a single-line replace.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content replaces the entire matched span and must be non-empty. A trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the replacement are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", + "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range (never cuts mid-line).\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content: a trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently.\n- After the replacement, every run of consecutive blank lines in the note's body — including runs within the replacement text — is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -1110,14 +1112,14 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to replace just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to replace just the single line containing start_anchor.", "type": "string", "minLength": 1 }, "content": { "type": "string", "minLength": 1, - "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty; use vault_delete_span to delete without replacement." + "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty." }, "first_match": { "default": false, @@ -1145,17 +1147,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -1207,7 +1209,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -1259,14 +1261,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -1288,7 +1290,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -1299,7 +1301,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -1374,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "inputSchema": { "type": "object", "properties": { @@ -1407,7 +1409,7 @@ { "name": "vault_update_properties", "title": "Update Properties", - "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\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; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1423,7 +1425,7 @@ "minLength": 1 }, "additionalProperties": {}, - "description": "Properties to merge. New keys are added; existing keys are overwritten; a null value deletes that key; unmentioned keys are preserved." + "description": "Properties to merge; a null value deletes that key." } }, "required": [ @@ -1445,7 +1447,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\", scheduled: null }) — change the description, set one date, and clear another\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Clearing is always explicit null — omitting a field leaves it untouched.\n- status manages the checkbox and the done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁 delete / [onCompletion:: delete]): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day. When set together with status \"done\", the new rule governs the spawn. Setting recurrence to null while completing removes the rule and completes without spawning.\n- on_completion: passed together with status, the submitted value governs the delete decision — \"keep\" while completing a \"delete\" task prevents the deletion.\n- position: applies to a heading move or an auto-done-lane move. Without a heading, it triggers a same-lane reorder to the given position; omitting position performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n\nErrors:\n- \"note not found\" — path does not exist\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- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox \"[c]\" is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or add_subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence for each: a stored line that parses back differently than submitted (see Obsidian syntax above), a duplicate tag removed from the line, or a completed recurring task whose rule yields no next occurrence (unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { @@ -1466,7 +1468,7 @@ "maximum": 9007199254740991 }, "status": { - "description": "Target status. \"done\" appends the ✅ date and, on a Kanban board, moves the card and its checklist sub-items to the done lane (sub-item checkboxes are left as they are); a task with 🏁 delete / [onCompletion:: delete] is removed from the file instead. \"cancelled\" appends the ❌ date.", + "description": "Target status. \"done\" appends the ✅ date; \"cancelled\" appends the ❌ date.", "type": "string", "enum": [ "todo", @@ -1494,7 +1496,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", "anyOf": [ { "type": "string", @@ -1621,7 +1623,7 @@ "minLength": 1 }, "position": { - "description": "Where within the target heading the task lands after a heading move or auto-done-lane move. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.", + "description": "Where within the target heading the task lands. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to \"top\" on heading moves.", "anyOf": [ { "type": "string", @@ -1664,7 +1666,7 @@ { "name": "vault_write_note", "title": "Write Note", - "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content — this is a full write, not a partial edit. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved).\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\n\nLimitation: Writes the entire body. Do not use for surgical edits to large files — existing content will be lost unless you include it in the body parameter.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content: existing content will be lost unless you include it in body, so do not use this tool for surgical edits to large files. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved); overwriting without properties keeps the existing property values.\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"cannot write note …: that path is not a file\" — a folder already has this name; choose another path\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; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1678,7 +1680,7 @@ "description": "Markdown body content — do not include frontmatter fences (---); use the properties parameter instead." }, "properties": { - "description": "Optional properties to merge. New keys are added; existing keys with matching names are overwritten; a null value deletes that key; unmentioned keys are preserved from the existing file.", + "description": "Optional properties to merge; a null value deletes that key.", "type": "object", "propertyNames": { "type": "string", 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 1a9e05601..63cf8d960 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 @@ -10,7 +10,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- heading is required on Kanban boards (notes with kanban-plugin frontmatter).\n- parent_block_id / parent_line: the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead of \"bottom\". Ignored when no heading or when placing under a parent.\n- priority: the plugin ranks \"no signifier\" (normal priority) between medium and low.\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day.\n- due / scheduled / start: omit a date rather than guessing — an absent 📅 means \"no deadline\".\n\nErrors:\n- \"note not found\" — path does not exist\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- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox \"[c]\" is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -30,23 +30,23 @@ "description": "The ^block-id for stable identification — letters, digits, and hyphens only ([a-zA-Z0-9-]+). Must be unique within the note." }, "heading": { - "description": "Target heading. Required on Kanban boards; optional on regular notes (omit to append at end of body).", + "description": "Target heading. On a regular note, omit to append at end of body.", "type": "string", "minLength": 1 }, "parent_block_id": { - "description": "^block-id (without the ^) of an existing task to nest under as a sub-task. Mutually exclusive with parent_line and heading.", + "description": "^block-id (without the ^) of an existing task to nest under as a sub-task.", "type": "string", "minLength": 1 }, "parent_line": { - "description": "1-based line number of an existing task to nest under as a sub-task. Mutually exclusive with parent_block_id and heading. Fragile if the file changed since the line was read.", + "description": "1-based line number of an existing task to nest under as a sub-task. Fragile if the file changed since the line was read.", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "position": { - "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to bottom. Kanban boards with new-card-insertion-method set to prepend default to top instead. Ignored when no heading or when placing under a parent.", + "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to bottom.", "anyOf": [ { "type": "string", @@ -74,7 +74,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", "type": "string", "minLength": 1 }, @@ -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\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\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- \"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 rather than risk skipping a configured .trash/; 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- 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.", "inputSchema": { "type": "object", "properties": { @@ -186,7 +186,7 @@ { "name": "vault_delete_span", "title": "Delete Span", - "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the deletion are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", + "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- After the deletion, every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", "inputSchema": { "type": "object", "properties": { @@ -201,7 +201,7 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to delete just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to delete just the single line containing start_anchor.", "type": "string", "minLength": 1 }, @@ -230,12 +230,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -293,7 +293,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", "inputSchema": { "type": "object", "properties": { @@ -318,7 +318,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF); !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links).\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF). When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -346,7 +346,7 @@ { "name": "vault_insert_at_anchor", "title": "Insert at Anchor", - "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the content is inserted as whole lines before or after it (never splits a line).\n- position: \"before\" inserts above the anchor line; \"after\" inserts below it.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n- first_match: when the anchor matches multiple lines, takes the first instead of erroring.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", + "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the insert never splits a line.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -371,7 +371,7 @@ "content": { "type": "string", "minLength": 1, - "description": "Content to insert (one or more lines), inserted verbatim as whole lines — blank lines are kept, and a trailing newline adds a blank line after the block." + "description": "Content to insert (one or more lines)." }, "first_match": { "default": false, @@ -495,7 +495,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -514,13 +514,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -678,6 +678,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -737,7 +738,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -774,7 +775,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — old_path or new_path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use vault-relative paths outside hidden folders (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted only after the destination and every backlink are written. If a backlink write failed, new_path exists and old_path is intact: delete the partial new_path, then re-run the move. If the final delete failed, both paths exist: delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed, nothing was written. If a later link write failed, the note already lives at new_path: fix the remaining links in place (the error names the note whose update failed) instead of re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted last, after the destination and every backlink are written. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed: nothing was written. If a later link write failed: the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -813,7 +814,7 @@ { "name": "vault_patch_note", "title": "Patch Note", - "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim — don't begin content with the target heading (it's rejected to avoid a duplicate). No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, that content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included in the parent section. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", + "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim. No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, the pre-existing content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF), so it includes its child headings. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", "inputSchema": { "type": "object", "properties": { @@ -830,15 +831,15 @@ "replace", "insert_before" ], - "description": "append | prepend | replace | insert_before. replace and insert_before require a heading; append and prepend work with or without one." + "description": "append | prepend | replace | insert_before." }, "content": { "type": "string", "minLength": 1, - "description": "Markdown content to insert, written verbatim with no separator added — end it with a newline to leave a blank line after the inserted block. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." + "description": "Markdown content to insert. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." }, "heading": { - "description": "Target heading text (case-sensitive exact match). Required for replace and insert_before. Optional for append/prepend (omit for file-level operation).", + "description": "Target heading text (case-sensitive exact match). Omit for a file-level append or prepend.", "type": "string", "minLength": 1 }, @@ -873,7 +874,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", "inputSchema": { "type": "object", "properties": { @@ -891,7 +892,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -902,13 +903,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -968,7 +969,7 @@ { "name": "vault_replace_in_note", "title": "Replace in Note", - "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text is matched in the body only — frontmatter properties are never searched. Include enough surrounding context to ensure uniqueness when the target text appears in multiple places.\n- old_text + new_text together determine the operation: a non-empty new_text is an edit; an empty new_text (\"\") is a deletion. No regex — exact text only.\n- replace_all_occurrences (default false) replaces only the first match — a safety default when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\n- \"oldText cannot be empty\" — old_text must be at least one character\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", + "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text: include enough surrounding context to ensure uniqueness when the target text appears in multiple places. No regex — exact text only.\n- new_text: after a deletion (new_text=\"\"), every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n- replace_all_occurrences: replacing only the first match is a safety default for when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\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; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", "inputSchema": { "type": "object", "properties": { @@ -1012,7 +1013,7 @@ { "name": "vault_replace_span", "title": "Replace Span", - "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and the entire line from start to end is replaced (never cuts mid-line). Omit end_anchor for a single-line replace.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content replaces the entire matched span and must be non-empty. A trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the replacement are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", + "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range (never cuts mid-line).\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content: a trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently.\n- After the replacement, every run of consecutive blank lines in the note's body — including runs within the replacement text — is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -1027,14 +1028,14 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to replace just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to replace just the single line containing start_anchor.", "type": "string", "minLength": 1 }, "content": { "type": "string", "minLength": 1, - "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty; use vault_delete_span to delete without replacement." + "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty." }, "first_match": { "default": false, @@ -1062,17 +1063,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -1124,7 +1125,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -1176,14 +1177,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -1205,7 +1206,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -1216,7 +1217,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -1291,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "inputSchema": { "type": "object", "properties": { @@ -1324,7 +1325,7 @@ { "name": "vault_update_properties", "title": "Update Properties", - "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\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; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1340,7 +1341,7 @@ "minLength": 1 }, "additionalProperties": {}, - "description": "Properties to merge. New keys are added; existing keys are overwritten; a null value deletes that key; unmentioned keys are preserved." + "description": "Properties to merge; a null value deletes that key." } }, "required": [ @@ -1362,7 +1363,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\", scheduled: null }) — change the description, set one date, and clear another\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Clearing is always explicit null — omitting a field leaves it untouched.\n- status manages the checkbox and the done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁 delete / [onCompletion:: delete]): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day. When set together with status \"done\", the new rule governs the spawn. Setting recurrence to null while completing removes the rule and completes without spawning.\n- on_completion: passed together with status, the submitted value governs the delete decision — \"keep\" while completing a \"delete\" task prevents the deletion.\n- position: applies to a heading move or an auto-done-lane move. Without a heading, it triggers a same-lane reorder to the given position; omitting position performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n\nErrors:\n- \"note not found\" — path does not exist\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- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox \"[c]\" is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or add_subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence for each: a stored line that parses back differently than submitted (see Obsidian syntax above), a duplicate tag removed from the line, or a completed recurring task whose rule yields no next occurrence (unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { @@ -1383,7 +1384,7 @@ "maximum": 9007199254740991 }, "status": { - "description": "Target status. \"done\" appends the ✅ date and, on a Kanban board, moves the card and its checklist sub-items to the done lane (sub-item checkboxes are left as they are); a task with 🏁 delete / [onCompletion:: delete] is removed from the file instead. \"cancelled\" appends the ❌ date.", + "description": "Target status. \"done\" appends the ✅ date; \"cancelled\" appends the ❌ date.", "type": "string", "enum": [ "todo", @@ -1411,7 +1412,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", "anyOf": [ { "type": "string", @@ -1538,7 +1539,7 @@ "minLength": 1 }, "position": { - "description": "Where within the target heading the task lands after a heading move or auto-done-lane move. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.", + "description": "Where within the target heading the task lands. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to \"top\" on heading moves.", "anyOf": [ { "type": "string", @@ -1581,7 +1582,7 @@ { "name": "vault_write_note", "title": "Write Note", - "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content — this is a full write, not a partial edit. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved).\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\n\nLimitation: Writes the entire body. Do not use for surgical edits to large files — existing content will be lost unless you include it in the body parameter.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content: existing content will be lost unless you include it in body, so do not use this tool for surgical edits to large files. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved); overwriting without properties keeps the existing property values.\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"cannot write note …: that path is not a file\" — a folder already has this name; choose another path\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; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1595,7 +1596,7 @@ "description": "Markdown body content — do not include frontmatter fences (---); use the properties parameter instead." }, "properties": { - "description": "Optional properties to merge. New keys are added; existing keys with matching names are overwritten; a null value deletes that key; unmentioned keys are preserved from the existing file.", + "description": "Optional properties to merge; a null value deletes that key.", "type": "object", "propertyNames": { "type": "string", 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 60f6eaada..0af226043 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 @@ -9,7 +9,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- heading is required on Kanban boards (notes with kanban-plugin frontmatter).\n- parent_block_id / parent_line: the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead of \"bottom\". Ignored when no heading or when placing under a parent.\n- priority: the plugin ranks \"no signifier\" (normal priority) between medium and low.\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day.\n- due / scheduled / start: omit a date rather than guessing — an absent 📅 means \"no deadline\".\n\nErrors:\n- \"note not found\" — path does not exist\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- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox \"[c]\" is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -29,23 +29,23 @@ "description": "The ^block-id for stable identification — letters, digits, and hyphens only ([a-zA-Z0-9-]+). Must be unique within the note." }, "heading": { - "description": "Target heading. Required on Kanban boards; optional on regular notes (omit to append at end of body).", + "description": "Target heading. On a regular note, omit to append at end of body.", "type": "string", "minLength": 1 }, "parent_block_id": { - "description": "^block-id (without the ^) of an existing task to nest under as a sub-task. Mutually exclusive with parent_line and heading.", + "description": "^block-id (without the ^) of an existing task to nest under as a sub-task.", "type": "string", "minLength": 1 }, "parent_line": { - "description": "1-based line number of an existing task to nest under as a sub-task. Mutually exclusive with parent_block_id and heading. Fragile if the file changed since the line was read.", + "description": "1-based line number of an existing task to nest under as a sub-task. Fragile if the file changed since the line was read.", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "position": { - "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to bottom. Kanban boards with new-card-insertion-method set to prepend default to top instead. Ignored when no heading or when placing under a parent.", + "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to bottom.", "anyOf": [ { "type": "string", @@ -73,7 +73,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", "type": "string", "minLength": 1 }, @@ -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\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\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- \"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 rather than risk skipping a configured .trash/; 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- 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.", "inputSchema": { "type": "object", "properties": { @@ -185,7 +185,7 @@ { "name": "vault_delete_span", "title": "Delete Span", - "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the deletion are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", + "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- After the deletion, every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", "inputSchema": { "type": "object", "properties": { @@ -200,7 +200,7 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to delete just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to delete just the single line containing start_anchor.", "type": "string", "minLength": 1 }, @@ -229,12 +229,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -292,7 +292,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", "inputSchema": { "type": "object", "properties": { @@ -317,7 +317,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF); !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links).\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF). When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -345,7 +345,7 @@ { "name": "vault_insert_at_anchor", "title": "Insert at Anchor", - "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the content is inserted as whole lines before or after it (never splits a line).\n- position: \"before\" inserts above the anchor line; \"after\" inserts below it.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n- first_match: when the anchor matches multiple lines, takes the first instead of erroring.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", + "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the insert never splits a line.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -370,7 +370,7 @@ "content": { "type": "string", "minLength": 1, - "description": "Content to insert (one or more lines), inserted verbatim as whole lines — blank lines are kept, and a trailing newline adds a blank line after the block." + "description": "Content to insert (one or more lines)." }, "first_match": { "default": false, @@ -494,7 +494,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -513,13 +513,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -677,6 +677,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -736,7 +737,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -773,7 +774,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — old_path or new_path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use vault-relative paths outside hidden folders (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted only after the destination and every backlink are written. If a backlink write failed, new_path exists and old_path is intact: delete the partial new_path, then re-run the move. If the final delete failed, both paths exist: delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed, nothing was written. If a later link write failed, the note already lives at new_path: fix the remaining links in place (the error names the note whose update failed) instead of re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted last, after the destination and every backlink are written. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed: nothing was written. If a later link write failed: the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -812,7 +813,7 @@ { "name": "vault_patch_note", "title": "Patch Note", - "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim — don't begin content with the target heading (it's rejected to avoid a duplicate). No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, that content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included in the parent section. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", + "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim. No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, the pre-existing content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF), so it includes its child headings. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", "inputSchema": { "type": "object", "properties": { @@ -829,15 +830,15 @@ "replace", "insert_before" ], - "description": "append | prepend | replace | insert_before. replace and insert_before require a heading; append and prepend work with or without one." + "description": "append | prepend | replace | insert_before." }, "content": { "type": "string", "minLength": 1, - "description": "Markdown content to insert, written verbatim with no separator added — end it with a newline to leave a blank line after the inserted block. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." + "description": "Markdown content to insert. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." }, "heading": { - "description": "Target heading text (case-sensitive exact match). Required for replace and insert_before. Optional for append/prepend (omit for file-level operation).", + "description": "Target heading text (case-sensitive exact match). Omit for a file-level append or prepend.", "type": "string", "minLength": 1 }, @@ -872,7 +873,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", "inputSchema": { "type": "object", "properties": { @@ -890,7 +891,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -901,13 +902,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -967,7 +968,7 @@ { "name": "vault_replace_in_note", "title": "Replace in Note", - "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text is matched in the body only — frontmatter properties are never searched. Include enough surrounding context to ensure uniqueness when the target text appears in multiple places.\n- old_text + new_text together determine the operation: a non-empty new_text is an edit; an empty new_text (\"\") is a deletion. No regex — exact text only.\n- replace_all_occurrences (default false) replaces only the first match — a safety default when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\n- \"oldText cannot be empty\" — old_text must be at least one character\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", + "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text: include enough surrounding context to ensure uniqueness when the target text appears in multiple places. No regex — exact text only.\n- new_text: after a deletion (new_text=\"\"), every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n- replace_all_occurrences: replacing only the first match is a safety default for when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\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; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", "inputSchema": { "type": "object", "properties": { @@ -1011,7 +1012,7 @@ { "name": "vault_replace_span", "title": "Replace Span", - "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and the entire line from start to end is replaced (never cuts mid-line). Omit end_anchor for a single-line replace.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content replaces the entire matched span and must be non-empty. A trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the replacement are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", + "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range (never cuts mid-line).\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content: a trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently.\n- After the replacement, every run of consecutive blank lines in the note's body — including runs within the replacement text — is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -1026,14 +1027,14 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to replace just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to replace just the single line containing start_anchor.", "type": "string", "minLength": 1 }, "content": { "type": "string", "minLength": 1, - "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty; use vault_delete_span to delete without replacement." + "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty." }, "first_match": { "default": false, @@ -1061,17 +1062,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -1123,7 +1124,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -1175,14 +1176,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -1204,7 +1205,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -1215,7 +1216,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -1290,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "inputSchema": { "type": "object", "properties": { @@ -1323,7 +1324,7 @@ { "name": "vault_update_properties", "title": "Update Properties", - "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\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; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1339,7 +1340,7 @@ "minLength": 1 }, "additionalProperties": {}, - "description": "Properties to merge. New keys are added; existing keys are overwritten; a null value deletes that key; unmentioned keys are preserved." + "description": "Properties to merge; a null value deletes that key." } }, "required": [ @@ -1361,7 +1362,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\", scheduled: null }) — change the description, set one date, and clear another\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Clearing is always explicit null — omitting a field leaves it untouched.\n- status manages the checkbox and the done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁 delete / [onCompletion:: delete]): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day. When set together with status \"done\", the new rule governs the spawn. Setting recurrence to null while completing removes the rule and completes without spawning.\n- on_completion: passed together with status, the submitted value governs the delete decision — \"keep\" while completing a \"delete\" task prevents the deletion.\n- position: applies to a heading move or an auto-done-lane move. Without a heading, it triggers a same-lane reorder to the given position; omitting position performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n\nErrors:\n- \"note not found\" — path does not exist\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- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox \"[c]\" is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or add_subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence for each: a stored line that parses back differently than submitted (see Obsidian syntax above), a duplicate tag removed from the line, or a completed recurring task whose rule yields no next occurrence (unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { @@ -1382,7 +1383,7 @@ "maximum": 9007199254740991 }, "status": { - "description": "Target status. \"done\" appends the ✅ date and, on a Kanban board, moves the card and its checklist sub-items to the done lane (sub-item checkboxes are left as they are); a task with 🏁 delete / [onCompletion:: delete] is removed from the file instead. \"cancelled\" appends the ❌ date.", + "description": "Target status. \"done\" appends the ✅ date; \"cancelled\" appends the ❌ date.", "type": "string", "enum": [ "todo", @@ -1410,7 +1411,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", "anyOf": [ { "type": "string", @@ -1537,7 +1538,7 @@ "minLength": 1 }, "position": { - "description": "Where within the target heading the task lands after a heading move or auto-done-lane move. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.", + "description": "Where within the target heading the task lands. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to \"top\" on heading moves.", "anyOf": [ { "type": "string", @@ -1580,7 +1581,7 @@ { "name": "vault_write_note", "title": "Write Note", - "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content — this is a full write, not a partial edit. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved).\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\n\nLimitation: Writes the entire body. Do not use for surgical edits to large files — existing content will be lost unless you include it in the body parameter.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content: existing content will be lost unless you include it in body, so do not use this tool for surgical edits to large files. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved); overwriting without properties keeps the existing property values.\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"cannot write note …: that path is not a file\" — a folder already has this name; choose another path\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; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1594,7 +1595,7 @@ "description": "Markdown body content — do not include frontmatter fences (---); use the properties parameter instead." }, "properties": { - "description": "Optional properties to merge. New keys are added; existing keys with matching names are overwritten; a null value deletes that key; unmentioned keys are preserved from the existing file.", + "description": "Optional properties to merge; a null value deletes that key.", "type": "object", "propertyNames": { "type": "string", 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 c0ced0c8a..043608939 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 @@ -8,7 +8,7 @@ { "name": "vault_create_task", "title": "Create Task", - "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Ship the feature\", block_id: \"ship-feature\", heading: \"Up Next\", subtasks: [\"Design\", \"Implement\", \"Test\"] }) — card with checklist stages\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Urgent fix\", block_id: \"urgent-fix\", heading: \"Active\", position: \"top\" }) — insert at the top of a lane instead of the default bottom\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card)\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\"). The note must already exist.\n- description (required): the task text (before metadata fields).\n- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note.\n- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body).\n- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: \"top\", \"bottom\", or a 1-based integer — where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to \"bottom\" (append). Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead. Ignored when no heading or when placing under a parent.\n- priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\". Omit for normal priority (the plugin ranks \"no signifier\" between medium and low).\n- recurrence: a Tasks plugin 🔁 rule in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically.\n- on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed. \"delete\" removes the task line on completion; \"keep\" leaves it in place.\n- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means \"no deadline\".\n- task_id: Tasks plugin 🆔 identifier for dependency chains.\n- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks).\n- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present).\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox '[c]' is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"dependsOn cannot be empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- heading is required on Kanban boards (notes with kanban-plugin frontmatter).\n- parent_block_id / parent_line: the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead of \"bottom\". Ignored when no heading or when placing under a parent.\n- priority: the plugin ranks \"no signifier\" (normal priority) between medium and low.\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day.\n- due / scheduled / start: omit a date rather than guessing — an absent 📅 means \"no deadline\".\n\nErrors:\n- \"note not found\" — path does not exist\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- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox \"[c]\" is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", "inputSchema": { "type": "object", "properties": { @@ -28,23 +28,23 @@ "description": "The ^block-id for stable identification — letters, digits, and hyphens only ([a-zA-Z0-9-]+). Must be unique within the note." }, "heading": { - "description": "Target heading. Required on Kanban boards; optional on regular notes (omit to append at end of body).", + "description": "Target heading. On a regular note, omit to append at end of body.", "type": "string", "minLength": 1 }, "parent_block_id": { - "description": "^block-id (without the ^) of an existing task to nest under as a sub-task. Mutually exclusive with parent_line and heading.", + "description": "^block-id (without the ^) of an existing task to nest under as a sub-task.", "type": "string", "minLength": 1 }, "parent_line": { - "description": "1-based line number of an existing task to nest under as a sub-task. Mutually exclusive with parent_block_id and heading. Fragile if the file changed since the line was read.", + "description": "1-based line number of an existing task to nest under as a sub-task. Fragile if the file changed since the line was read.", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "position": { - "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to bottom. Kanban boards with new-card-insertion-method set to prepend default to top instead. Ignored when no heading or when placing under a parent.", + "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to bottom.", "anyOf": [ { "type": "string", @@ -72,7 +72,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", "type": "string", "minLength": 1 }, @@ -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\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\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- \"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 rather than risk skipping a configured .trash/; 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- 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.", "inputSchema": { "type": "object", "properties": { @@ -184,7 +184,7 @@ { "name": "vault_delete_span", "title": "Delete Span", - "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the deletion are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", + "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- After the deletion, every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", "inputSchema": { "type": "object", "properties": { @@ -199,7 +199,7 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to delete just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to delete just the single line containing start_anchor.", "type": "string", "minLength": 1 }, @@ -228,12 +228,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -291,7 +291,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", "inputSchema": { "type": "object", "properties": { @@ -316,7 +316,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF) readable via vault_read_file; !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image file is still cheap to read.\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF) readable via vault_read_file. When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image is still cheap to read.\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -344,7 +344,7 @@ { "name": "vault_insert_at_anchor", "title": "Insert at Anchor", - "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the content is inserted as whole lines before or after it (never splits a line).\n- position: \"before\" inserts above the anchor line; \"after\" inserts below it.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n- first_match: when the anchor matches multiple lines, takes the first instead of erroring.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", + "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the insert never splits a line.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -369,7 +369,7 @@ "content": { "type": "string", "minLength": 1, - "description": "Content to insert (one or more lines), inserted verbatim as whole lines — blank lines are kept, and a trailing newline adds a blank line after the block." + "description": "Content to insert (one or more lines)." }, "first_match": { "default": false, @@ -398,17 +398,18 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nParameters:\n- folder: folder path filter (e.g. \"attachments\" or \"Projects/media\"), searched recursively; omit for the whole vault. This tool reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n- extensions: restrict to these extensions — case-insensitive, with or without the leading dot (\".png\" and \"png\" both work)\n- limit: maximum entries returned (default 50). extension_counts and total always reflect the full filtered set, not just the returned page.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Files of supported types are readable via vault_read_file.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: Reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Only supported types are readable via vault_read_file.", "inputSchema": { "type": "object", "properties": { "folder": { - "description": "Folder path to search recursively (e.g. \"attachments\"). Omit to list the whole vault.", + "description": "Folder path to search recursively (e.g. \"attachments\" or \"Projects/media\"). Omit to list the whole vault.", "type": "string", "minLength": 1 }, "extensions": { "description": "Only include these extensions, case-insensitive, leading dot optional (e.g. [\".png\", \"jpg\"]).", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -533,7 +534,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -552,13 +553,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -716,6 +717,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -775,7 +777,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -812,7 +814,7 @@ { "name": "vault_move_note", "title": "Move Note", - "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — old_path or new_path is missing the .md extension; both paths must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — old_path or new_path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use vault-relative paths outside hidden folders (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted only after the destination and every backlink are written. If a backlink write failed, new_path exists and old_path is intact: delete the partial new_path, then re-run the move. If the final delete failed, both paths exist: delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed, nothing was written. If a later link write failed, the note already lives at new_path: fix the remaining links in place (the error names the note whose update failed) instead of re-running the move, whose old_path no longer exists.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted last, after the destination and every backlink are written. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed: nothing was written. If a later link write failed: the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", "inputSchema": { "type": "object", "properties": { @@ -851,7 +853,7 @@ { "name": "vault_patch_note", "title": "Patch Note", - "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim — don't begin content with the target heading (it's rejected to avoid a duplicate). No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, that content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included in the parent section. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", + "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim. No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, the pre-existing content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF), so it includes its child headings. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", "inputSchema": { "type": "object", "properties": { @@ -868,15 +870,15 @@ "replace", "insert_before" ], - "description": "append | prepend | replace | insert_before. replace and insert_before require a heading; append and prepend work with or without one." + "description": "append | prepend | replace | insert_before." }, "content": { "type": "string", "minLength": 1, - "description": "Markdown content to insert, written verbatim with no separator added — end it with a newline to leave a blank line after the inserted block. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." + "description": "Markdown content to insert. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." }, "heading": { - "description": "Target heading text (case-sensitive exact match). Required for replace and insert_before. Optional for append/prepend (omit for file-level operation).", + "description": "Target heading text (case-sensitive exact match). Omit for a file-level append or prepend.", "type": "string", "minLength": 1 }, @@ -911,7 +913,7 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\", raw: true }) — the canvas's exact JSON source\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\nExample: vault_read_file({ path: \"papers/research.pdf\", raw: true }) — each page rendered as an image block\n\nWhat each type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. Set raw: true for the exact JSON source instead (geometry, ids, colors — full fidelity).\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved. Set raw: true for page images instead — each page rendered and returned as an image block, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n- Line paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhat each file type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n\nraw: true switches a canvas or PDF to its other form:\n- Canvas: the exact JSON source (geometry, ids, colors — full fidelity) instead of the outline.\n- PDF: each page rendered and returned as an image block instead of extracted text, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n\nLine paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\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 (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON, so no outline can be built; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- an image that cannot be decoded (corrupt, empty, or not an image despite its extension) fails with the decoder's message, e.g. \"Input buffer contains unsupported image format\"; replace or re-export the file\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", "inputSchema": { "type": "object", "properties": { @@ -955,7 +957,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", "inputSchema": { "type": "object", "properties": { @@ -973,7 +975,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -984,13 +986,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -1050,7 +1052,7 @@ { "name": "vault_replace_in_note", "title": "Replace in Note", - "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text is matched in the body only — frontmatter properties are never searched. Include enough surrounding context to ensure uniqueness when the target text appears in multiple places.\n- old_text + new_text together determine the operation: a non-empty new_text is an edit; an empty new_text (\"\") is a deletion. No regex — exact text only.\n- replace_all_occurrences (default false) replaces only the first match — a safety default when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\n- \"oldText cannot be empty\" — old_text must be at least one character\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", + "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text: include enough surrounding context to ensure uniqueness when the target text appears in multiple places. No regex — exact text only.\n- new_text: after a deletion (new_text=\"\"), every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n- replace_all_occurrences: replacing only the first match is a safety default for when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\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; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", "inputSchema": { "type": "object", "properties": { @@ -1094,7 +1096,7 @@ { "name": "vault_replace_span", "title": "Replace Span", - "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and the entire line from start to end is replaced (never cuts mid-line). Omit end_anchor for a single-line replace.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content replaces the entire matched span and must be non-empty. A trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- Blank-line runs left by the replacement are collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", + "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range (never cuts mid-line).\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content: a trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently.\n- After the replacement, every run of consecutive blank lines in the note's body — including runs within the replacement text — is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", "inputSchema": { "type": "object", "properties": { @@ -1109,14 +1111,14 @@ "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." }, "end_anchor": { - "description": "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to replace just the single line containing start_anchor.", + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to replace just the single line containing start_anchor.", "type": "string", "minLength": 1 }, "content": { "type": "string", "minLength": 1, - "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty; use vault_delete_span to delete without replacement." + "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty." }, "first_match": { "default": false, @@ -1144,17 +1146,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -1206,7 +1208,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -1258,14 +1260,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -1287,7 +1289,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -1298,7 +1300,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -1373,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "inputSchema": { "type": "object", "properties": { @@ -1406,7 +1408,7 @@ { "name": "vault_update_properties", "title": "Update Properties", - "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\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; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1422,7 +1424,7 @@ "minLength": 1 }, "additionalProperties": {}, - "description": "Properties to merge. New keys are added; existing keys are overwritten; a null value deletes that key; unmentioned keys are preserved." + "description": "Properties to merge; a null value deletes that key." } }, "required": [ @@ -1444,7 +1446,7 @@ { "name": "vault_update_task", "title": "Update Task", - "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\", position: \"bottom\" }) — move to the bottom of the lane\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\" }) — change description and set due date\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", due: null }) — clear a date field\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", task_id: \"abc123\" }) — set a Tasks plugin 🆔 identifier\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", on_completion: \"delete\" }) — set the task to be removed on completion (null clears the field)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", position: 1 }) — same-lane reorder to the top without a heading move\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- path (required): vault-relative path to the note (must end in \".md\").\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Every field passed is applied in the same single write:\n - status: \"todo\" | \"in_progress\" | \"done\" | \"cancelled\". Manages checkbox and done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n - priority: \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" sets the signifier; null removes it.\n - recurrence: sets the Tasks plugin 🔁 rule, in natural language (\"every week\", \"every month on the 15th\", \"every 3 days when done\" — \"when done\" bases the next occurrence on the completion day); null removes it. Passed together with status \"done\", the new rule governs the spawn (recurrence: null completes without spawning).\n - on_completion: \"delete\" or \"keep\" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting \"keep\" while completing a \"delete\" task prevents the deletion. See the status bullet for delete behavior details.\n - description: replaces the task text. Metadata fields and block_id are preserved.\n - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it.\n - task_id: string sets the Tasks plugin 🆔; null clears it.\n - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it.\n - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept. For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.\n - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.\n - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.\n - position: \"top\", \"bottom\", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n - Clearing is always explicit null — omitting a field leaves it untouched.\n- format: \"emoji\" or \"dataview\" — overrides the auto-detected Tasks plugin format.\n\nErrors:\n- \"note not found\" — path does not exist\n- \"path must end in …\" — add the .md extension\n- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox '[c]' is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"dependsOn cannot be empty\" / \"addSubtasks cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only text or an empty array (use null to clear depends_on)\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like \"🔁 every week\", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\", scheduled: null }) — change the description, set one date, and clear another\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Clearing is always explicit null — omitting a field leaves it untouched.\n- status manages the checkbox and the done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁 delete / [onCompletion:: delete]): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day. When set together with status \"done\", the new rule governs the spawn. Setting recurrence to null while completing removes the rule and completes without spawning.\n- on_completion: passed together with status, the submitted value governs the delete decision — \"keep\" while completing a \"delete\" task prevents the deletion.\n- position: applies to a heading move or an auto-done-lane move. Without a heading, it triggers a same-lane reorder to the given position; omitting position performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n\nErrors:\n- \"note not found\" — path does not exist\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- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox \"[c]\" is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or add_subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence for each: a stored line that parses back differently than submitted (see Obsidian syntax above), a duplicate tag removed from the line, or a completed recurring task whose rule yields no next occurrence (unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", "inputSchema": { "type": "object", "properties": { @@ -1465,7 +1467,7 @@ "maximum": 9007199254740991 }, "status": { - "description": "Target status. \"done\" appends the ✅ date and, on a Kanban board, moves the card and its checklist sub-items to the done lane (sub-item checkboxes are left as they are); a task with 🏁 delete / [onCompletion:: delete] is removed from the file instead. \"cancelled\" appends the ❌ date.", + "description": "Target status. \"done\" appends the ✅ date; \"cancelled\" appends the ❌ date.", "type": "string", "enum": [ "todo", @@ -1493,7 +1495,7 @@ ] }, "recurrence": { - "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", "anyOf": [ { "type": "string", @@ -1620,7 +1622,7 @@ "minLength": 1 }, "position": { - "description": "Where within the target heading the task lands after a heading move or auto-done-lane move. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any trailing section content). Defaults to \"top\" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.", + "description": "Where within the target heading the task lands. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to \"top\" on heading moves.", "anyOf": [ { "type": "string", @@ -1663,7 +1665,7 @@ { "name": "vault_write_note", "title": "Write Note", - "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content — this is a full write, not a partial edit. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved).\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\n\nLimitation: Writes the entire body. Do not use for surgical edits to large files — existing content will be lost unless you include it in the body parameter.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n- \"concurrent write in progress\" — another write to this note is in flight; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content: existing content will be lost unless you include it in body, so do not use this tool for surgical edits to large files. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved); overwriting without properties keeps the existing property values.\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"cannot write note …: that path is not a file\" — a folder already has this name; choose another path\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; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", "inputSchema": { "type": "object", "properties": { @@ -1677,7 +1679,7 @@ "description": "Markdown body content — do not include frontmatter fences (---); use the properties parameter instead." }, "properties": { - "description": "Optional properties to merge. New keys are added; existing keys with matching names are overwritten; a null value deletes that key; unmentioned keys are preserved from the existing file.", + "description": "Optional properties to merge; a null value deletes that key.", "type": "object", "propertyNames": { "type": "string", 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 new file mode 100644 index 000000000..0660f94fe --- /dev/null +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json @@ -0,0 +1,1954 @@ +{ + "env": { + "OBSIDIAN_SYNC": "true" + }, + "description": "Read, write, and search an Obsidian vault. Provides hybrid search, tag queries, and a structured memory layer (About Me/) for personalization across conversations.", + "instructions": "Read, write, and search an Obsidian vault. Use vault_search and vault_read_note to find and read notes; vault_read_file for images, canvases, and other non-markdown files. Use vault_get_memory to retrieve user preferences and context from About Me/ files. Use vault_write_note and vault_update_memory for writes.\n\nVault content is Obsidian Flavored Markdown. Write tools pass content through without escaping — be intentional about Obsidian syntax (#, [[, %%, etc.) in inputs.", + "tools": [ + { + "name": "vault_create_task", + "title": "Create Task", + "description": "Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped — starting work is vault_update_task's job. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview).\n\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Fix login bug\", block_id: \"fix-login\", heading: \"Active\", priority: \"high\", due: \"2026-09-15\" })\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Sub-bug\", block_id: \"sub-bug\", parent_block_id: \"fix-login\", due: \"2026-09-01\" }) — full sub-task under a parent identified by block_id\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Quick fix\", block_id: \"quick-fix\", parent_line: 42 }) — sub-task under a parent identified by line number\nExample: vault_create_task({ path: \"TASKS.md\", description: \"Mid-priority\", block_id: \"mid-priority\", heading: \"Active\", position: 3 }) — insert as the 3rd card in the lane\n\nWhen to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id) so the card round-trips through vault_list_tasks with all fields intact. For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.\n\nParameters:\n- heading is required on Kanban boards (notes with kanban-plugin frontmatter).\n- parent_block_id / parent_line: the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives.\n- position: Kanban boards with new-card-insertion-method set to \"prepend\" default to \"top\" instead of \"bottom\". Ignored when no heading or when placing under a parent.\n- priority: the plugin ranks \"no signifier\" (normal priority) between medium and low.\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day.\n- due / scheduled / start: omit a date rather than guessing — an absent 📅 means \"no deadline\".\n\nErrors:\n- \"note not found\" — path does not exist\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- \"heading required for Kanban boards\" — kanban-plugin note without heading\n- \"heading \"X\" not found; available: ...\" — no heading matches; the error lists the note's headings\n- \"cannot place at position N under \"X\" — the heading appears N times\" — integer position on a note with duplicate heading names; rename one section to make it unique\n- \"parent task not found\" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment\n- \"checkbox \"[c]\" is a NON_TASK status\" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server\n- \"parentBlockId and parentLine are mutually exclusive\" — both parent_block_id and parent_line were passed; drop one\n- \"parent and heading are mutually exclusive\" — a parent (parent_block_id or parent_line) and heading were both passed; drop one\n- \"blockId ... already exists in this note\" — pick a block_id not yet used in the note\n- \"blockId ... contains invalid characters\" — block_id must match [a-zA-Z0-9-]+\n- \"description is empty\" / \"subtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"subtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"invalid date\" — a date param fails calendar validation\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as \"field: before → after\", with \"(none)\" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Vault-relative path to the note (must end in \".md\"). The note must already exist. Use the exact letter case." + }, + "description": { + "type": "string", + "minLength": 1, + "description": "The task text (before metadata fields)." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "The ^block-id for stable identification — letters, digits, and hyphens only ([a-zA-Z0-9-]+). Must be unique within the note." + }, + "heading": { + "description": "Target heading. On a regular note, omit to append at end of body.", + "type": "string", + "minLength": 1 + }, + "parent_block_id": { + "description": "^block-id (without the ^) of an existing task to nest under as a sub-task.", + "type": "string", + "minLength": 1 + }, + "parent_line": { + "description": "1-based line number of an existing task to nest under as a sub-task. Fragile if the file changed since the line was read.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "position": { + "description": "Where within the heading section the task is placed. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to bottom.", + "anyOf": [ + { + "type": "string", + "enum": [ + "top", + "bottom" + ] + }, + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + ] + }, + "priority": { + "description": "Priority signifier (🔺⏫🔼🔽⏬). Omit for normal priority — no signifier is written.", + "type": "string", + "enum": [ + "highest", + "high", + "medium", + "low", + "lowest" + ] + }, + "recurrence": { + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\"). Completing the task spawns its next occurrence.", + "type": "string", + "minLength": 1 + }, + "on_completion": { + "description": "Tasks plugin 🏁 onCompletion action. \"delete\" removes the task line on completion; \"keep\" leaves it in place.", + "type": "string", + "enum": [ + "delete", + "keep" + ] + }, + "due": { + "description": "Deadline (📅), YYYY-MM-DD, calendar-validated. Omit when there is no deadline.", + "type": "string", + "minLength": 1 + }, + "scheduled": { + "description": "Day the work is planned for (⏳), YYYY-MM-DD, calendar-validated.", + "type": "string", + "minLength": 1 + }, + "start": { + "description": "Earliest day work can begin (🛫), YYYY-MM-DD, calendar-validated.", + "type": "string", + "minLength": 1 + }, + "task_id": { + "description": "Tasks plugin 🆔 identifier other tasks can name in depends_on.", + "type": "string", + "minLength": 1 + }, + "depends_on": { + "description": "Tasks plugin ⛔ dependency IDs (🆔 values of other tasks). Non-empty; omit when there are no dependencies.", + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "subtasks": { + "description": "Checklist item descriptions — created as indented todo lines under the card (no metadata). For full sub-tasks with dates, priority, and block_id, make a separate call with parent_block_id.", + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "format": { + "description": "Field format. Default: auto-detected from .obsidian/ config, falling back to emoji.", + "type": "string", + "enum": [ + "emoji", + "dataview" + ] + } + }, + "required": [ + "path", + "description", + "block_id" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_delete_memory", + "title": "Delete Memory Entry", + "description": "Delete a single dated entry from a memory file in About Me/. Both date and entry text are required for exact matching — ensures only the intended entry is removed.\n\nExample: vault_delete_memory({ file: \"Opinions\", section: \"AI tooling & memory (newest first)\", date: \"2026-05-01\", entry: \"Prefer X over Y\" })\n\nWhen to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares `entry-policy: living` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching.\nPrefer vault_delete_note for deleting entire non-protected notes.\n\nParameters:\n- date + entry together uniquely identify the bullet line within the given section. If multiple entries share the same date and text, deletion fails as ambiguous.\n- section scopes the match — an identical entry under a different heading is not found. Section matching is case-insensitive, with or without the \"(newest first)\" suffix.\n\nErrors:\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would target a hidden file; memory files are always visible notes).\n- \"date must be a real ISO calendar date\" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with vault_delete_span or a manual edit.\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"no entry matching …\" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section).\n- \"ambiguous: N entries match …\" — more than one identical bullet exists in the section (e.g. from hand edits, sync conflicts, or entries predating duplicate protection; vault_update_memory refuses to write exact duplicates). Remove the extra copy with vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or a manual edit, then retry.\n- \"refusing memory write: … would shrink content\" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs vault_delete_span or a manual edit.\n\nReturns: Confirmation message.", + "inputSchema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "minLength": 1, + "description": "Memory file name without .md (e.g. \"Principles\"). Use the exact letter case." + }, + "section": { + "type": "string", + "minLength": 1, + "description": "H2 section heading containing the entry. Matched case-insensitively, with or without the \"(newest first)\" suffix." + }, + "date": { + "type": "string", + "minLength": 1, + "description": "ISO YYYY-MM-DD date of the entry (e.g. \"2026-05-01\"). Must match the date shown by vault_get_memory." + }, + "entry": { + "type": "string", + "minLength": 1, + "description": "Exact entry text as shown by vault_get_memory — without the \"- **YYYY-MM-DD**: \" prefix or bullet." + } + }, + "required": [ + "file", + "section", + "date", + "entry" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "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.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Vault-relative path of the note to delete, including the \".md\" extension. Use the exact letter case." + }, + "prune_empty_folders": { + "default": false, + "description": "When true, also remove parent folders the delete leaves empty. Default false.", + "type": "boolean" + } + }, + "required": [ + "path" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_delete_span", + "title": "Delete Span", + "description": "Delete a contiguous block of whole lines from a note's body by referencing short anchor substrings instead of reproducing the full block text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_delete_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\" }) — deletes the one table row whose line contains that fragment.\nExample: vault_delete_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\" }) — deletes from the start anchor line through the end anchor line.\n\nWhen to use: Removing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place edits (this tool only deletes). To replace a block, prefer vault_replace_span (one atomic step); otherwise delete it here, then vault_patch_note to add the new content.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete.\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted.\n- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring.\n- After the deletion, every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n\nReturns: Confirmation with lines removed and a truncated preview of the deleted text.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Vault-relative path to the note, including the \".md\" extension (e.g. \"Tracker.md\", \"Notes/Plan.md\"). Use the exact letter case." + }, + "start_anchor": { + "type": "string", + "minLength": 1, + "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." + }, + "end_anchor": { + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to delete just the single line containing start_anchor.", + "type": "string", + "minLength": 1 + }, + "first_match": { + "default": false, + "description": "If an anchor matches more than one line, delete using the first match instead of erroring (default: false — ambiguity is an error).", + "type": "boolean" + } + }, + "required": [ + "path", + "start_anchor" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_find_orphans", + "title": "Find Orphans", + "description": "Find notes with no incoming links from other notes — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "limit": { + "default": 50, + "description": "Max results (default 50)", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "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.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Exact vault-relative path including .md or .canvas extension (e.g. \"Projects/vault-cortex.md\"). Case-sensitive." + } + }, + "required": [ + "path" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_get_daily_note", + "title": "Get Daily Note", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. To append content to a daily note section, use the returned path with vault_patch_note. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, create the note with vault_write_note using the returned path.", + "inputSchema": { + "type": "object", + "properties": { + "date": { + "description": "YYYY-MM-DD (e.g. \"2026-05-13\", \"2025-12-31\"). Defaults to today in the server's timezone. Invalid formats like \"May 13\" return an error.", + "type": "string", + "minLength": 1 + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_get_memory", + "title": "Get Memory", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. It gives complete coverage from a known date without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would be a hidden file; memory files are always visible notes)\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", + "inputSchema": { + "type": "object", + "properties": { + "file": { + "description": "Memory file name without .md (e.g. \"Principles\", \"Opinions\"). Use the exact letter case.", + "type": "string", + "minLength": 1 + }, + "section": { + "description": "H2 section heading (e.g. \"Decision heuristics (newest first)\"). Matched case-insensitively, with or without the \"(newest first)\" suffix. Omit to read the whole file (with on_or_after, every H2 section's entries). Call vault_list_memory_files first to discover valid names.", + "type": "string", + "minLength": 1 + }, + "on_or_after": { + "description": "Inclusive date filter (YYYY-MM-DD). When provided with file, returns structured JSON entries dated on or after this date instead of raw markdown. Requires a file; add section to scope to one H2 section.", + "type": "string", + "minLength": 1 + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_get_outgoing_links", + "title": "Get Outgoing Links", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF) readable via vault_read_file. When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image is still cheap to read.\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Exact vault-relative path including .md or .canvas extension (e.g. \"Projects/vault-cortex.md\"). Case-sensitive." + } + }, + "required": [ + "path" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_insert_at_anchor", + "title": "Insert at Anchor", + "description": "Insert content as whole lines before or after a specific line identified by a short anchor substring. Case-sensitive matching; an anchor matching more than one line is an error unless first_match is set. Same anchor resolution as vault_delete_span. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_insert_at_anchor({ path: \"Tracker.md\", anchor: \"| 2024-03-02 | Acme\", position: \"after\", content: \"| 2024-03-03 | Beta Corp | New entry |\" }) — inserts a new table row after the matched row.\nExample: vault_insert_at_anchor({ path: \"Notes/Plan.md\", anchor: \"## Phase 2\", position: \"before\", content: \"> [!note] Phase 1 must close before this starts.\\n\" }) — inserts a callout and a blank line above the Phase 2 heading.\n\nWhen to use: Adding content at a precise location identified by a nearby line's text, without needing to know the heading structure. Good for inserting rows into tables, adding items into lists at a specific position, or placing content relative to a known landmark line.\nPrefer vault_patch_note for heading-targeted inserts (append/prepend to a section). Prefer vault_replace_span when replacing a block rather than inserting next to it.\n\nParameters:\n- anchor locates a full line — the insert never splits a line.\n- content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"anchor not found\" — fragment not on any line; verify with vault_read_note\n- \"ambiguous anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Inserted lines anchor in \" — N counts the lines content supplied.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Vault-relative path to the note, including the \".md\" extension (e.g. \"Notes/Plan.md\", \"Tracker.md\"). Use the exact letter case." + }, + "anchor": { + "type": "string", + "minLength": 1, + "description": "Short, unique substring on the line to insert next to (case-sensitive). Pick a brief fragment — do not paste the whole line." + }, + "position": { + "type": "string", + "enum": [ + "before", + "after" + ], + "description": "\"before\" places the content on the lines above the anchor line; \"after\" places it on the lines below. The anchor line itself is never changed." + }, + "content": { + "type": "string", + "minLength": 1, + "description": "Content to insert (one or more lines)." + }, + "first_match": { + "default": false, + "description": "If the anchor matches more than one line, use the first match instead of erroring (default: false — ambiguity is an error).", + "type": "boolean" + } + }, + "required": [ + "path", + "anchor", + "position", + "content" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_list_files", + "title": "List Files", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: Reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Only supported types are readable via vault_read_file.", + "inputSchema": { + "type": "object", + "properties": { + "folder": { + "description": "Folder path to search recursively (e.g. \"attachments\" or \"Projects/media\"). Omit to list the whole vault.", + "type": "string", + "minLength": 1 + }, + "extensions": { + "description": "Only include these extensions, case-insensitive, leading dot optional (e.g. [\".png\", \"jpg\"]).", + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "limit": { + "default": 50, + "description": "Max entries returned (default 50).", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_list_memory_files", + "title": "List Memory Files", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory, vault_update_memory, or vault_delete_memory. Always call this first to get valid file and section names, and to check a file's entry_policy before pruning entries.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "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\"]).", + "inputSchema": { + "type": "object", + "properties": { + "folder": { + "description": "Vault-relative folder to list (e.g. \"About Me\", \"Projects\").", + "type": "string", + "minLength": 1 + }, + "glob": { + "description": "Glob pattern for note paths (e.g. \"**/*session-log*.md\").", + "type": "string", + "minLength": 1 + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_list_property_keys", + "title": "List Property Keys", + "description": "Discover all property keys in the vault with note counts and sample values. Lets you understand the vault's metadata schema without reading individual notes.\n\nExample: vault_list_property_keys() returns [{ key: \"tags\", count: 342, sample_values: [\"session-log\", \"project\"] }, ...]\n\nWhen to use: Discovering what properties exist before searching by property. Good first step for vault orientation alongside vault_list_tags.\nPrefer vault_list_property_values when you need the full list of values for a specific key. Prefer vault_search_by_property to find notes matching a specific key-value pair.\n\nParameters:\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 scan the entire vault.\n\nBehavior: Only frontmatter properties count; inline Dataview fields (key:: value) are not listed. count is the number of notes that have the key, including notes where its value is empty (null). sample_values are the key's 3 most frequent values, counting each array element separately, returned as strings (checkbox values as \"1\" and \"0\"); null values are skipped.\n\nErrors:\n- An empty vault or folder returns an empty array, not an error.\n\nReturns: JSON array of { key, count, sample_values } sorted by count descending, then by key.", + "inputSchema": { + "type": "object", + "properties": { + "folder": { + "description": "Restrict to a folder (e.g. \"Projects\")", + "type": "string", + "minLength": 1 + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_list_property_values", + "title": "List Property Values", + "description": "List distinct values for a specific property key with how often each occurs. Useful for discovering the range of values a property takes before searching.\n\nExample: vault_list_property_values({ key: \"status\" }) returns [{ value: \"done\", count: 211 }, { value: \"active\", count: 47 }, ...]\n\nWhen to use: Enumerating possible values for a property key before calling vault_search_by_property. Call vault_list_property_keys first to discover valid key names.\n\nParameters:\n- key is case-sensitive and must match exactly as returned by vault_list_property_keys.\n- folder names a whole folder and includes its subfolders: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\". Matching ignores ASCII letter case.\n- limit (default 50) applies after sorting by count descending, so you always get the most-used values first. Nothing in the response signals truncation: exactly limit values may mean more exist, which is common for keys with many distinct values like \"title\" or \"created\"; raise limit to check.\n\nBehavior:\n- Handles both scalar properties (status: \"active\") and array properties (tags: [\"a\", \"b\"]). Array elements are unpacked and counted individually, so the sum of counts may exceed the note count.\n- Values are grouped first and turned into strings after, so a number and the same digits written as text (1 and \"1\") come back as two separate \"1\" rows.\n- Checkbox values are stored as 1 and 0, so true and false come back as \"1\" and \"0\", counted with the numbers 1 and 0.\n- vault_search_by_property compares values as text, so value \"1\" matches the number 1, the text \"1\", and a checked checkbox.\n- null values are skipped.\n\nErrors:\n- An unknown key or empty folder returns an empty array, not an error.\n\nReturns: JSON array of { value, count } sorted by count descending.", + "inputSchema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "description": "Property key name — use vault_list_property_keys to discover valid keys (e.g. \"status\", \"type\", \"tags\")." + }, + "folder": { + "description": "Restrict to a folder (e.g. \"Projects\")", + "type": "string", + "minLength": 1 + }, + "limit": { + "default": 50, + "description": "Max values to return (default 50).", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "key" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_list_tags", + "title": "List Tags", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", + "inputSchema": { + "type": "object", + "properties": {}, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_list_tasks", + "title": "List Tasks", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "inputSchema": { + "type": "object", + "properties": { + "status": { + "default": "not_done", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", + "anyOf": [ + { + "type": "string", + "enum": [ + "not_done", + "todo", + "in_progress", + "done", + "cancelled", + "all" + ] + }, + { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "not_done", + "todo", + "in_progress", + "done", + "cancelled", + "all" + ] + } + } + ] + }, + "due": { + "description": "Due date (📅 / [due:: ]) bounds", + "type": "object", + "properties": { + "before": { + "description": "Exclusive upper bound (YYYY-MM-DD) — strictly earlier dates", + "type": "string", + "minLength": 1 + }, + "on": { + "description": "Exact date match (YYYY-MM-DD)", + "type": "string", + "minLength": 1 + }, + "after": { + "description": "Exclusive lower bound (YYYY-MM-DD) — strictly later dates", + "type": "string", + "minLength": 1 + } + } + }, + "scheduled": { + "description": "Scheduled date (⏳ / [scheduled:: ]) bounds", + "type": "object", + "properties": { + "before": { + "description": "Exclusive upper bound (YYYY-MM-DD) — strictly earlier dates", + "type": "string", + "minLength": 1 + }, + "on": { + "description": "Exact date match (YYYY-MM-DD)", + "type": "string", + "minLength": 1 + }, + "after": { + "description": "Exclusive lower bound (YYYY-MM-DD) — strictly later dates", + "type": "string", + "minLength": 1 + } + } + }, + "start": { + "description": "Start date (🛫 / [start:: ]) bounds", + "type": "object", + "properties": { + "before": { + "description": "Exclusive upper bound (YYYY-MM-DD) — strictly earlier dates", + "type": "string", + "minLength": 1 + }, + "on": { + "description": "Exact date match (YYYY-MM-DD)", + "type": "string", + "minLength": 1 + }, + "after": { + "description": "Exclusive lower bound (YYYY-MM-DD) — strictly later dates", + "type": "string", + "minLength": 1 + } + } + }, + "done": { + "description": "Done date (✅ / [completion:: ]) bounds", + "type": "object", + "properties": { + "before": { + "description": "Exclusive upper bound (YYYY-MM-DD) — strictly earlier dates", + "type": "string", + "minLength": 1 + }, + "on": { + "description": "Exact date match (YYYY-MM-DD)", + "type": "string", + "minLength": 1 + }, + "after": { + "description": "Exclusive lower bound (YYYY-MM-DD) — strictly later dates", + "type": "string", + "minLength": 1 + } + } + }, + "created": { + "description": "Created date (➕ / [created:: ]) bounds", + "type": "object", + "properties": { + "before": { + "description": "Exclusive upper bound (YYYY-MM-DD) — strictly earlier dates", + "type": "string", + "minLength": 1 + }, + "on": { + "description": "Exact date match (YYYY-MM-DD)", + "type": "string", + "minLength": 1 + }, + "after": { + "description": "Exclusive lower bound (YYYY-MM-DD) — strictly later dates", + "type": "string", + "minLength": 1 + } + } + }, + "cancelled": { + "description": "Cancelled date (❌ / [cancelled:: ]) bounds", + "type": "object", + "properties": { + "before": { + "description": "Exclusive upper bound (YYYY-MM-DD) — strictly earlier dates", + "type": "string", + "minLength": 1 + }, + "on": { + "description": "Exact date match (YYYY-MM-DD)", + "type": "string", + "minLength": 1 + }, + "after": { + "description": "Exclusive lower bound (YYYY-MM-DD) — strictly later dates", + "type": "string", + "minLength": 1 + } + } + }, + "priority": { + "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "highest", + "high", + "medium", + "low", + "lowest", + "none" + ] + } + }, + "folder": { + "description": "Restrict to a folder (e.g. \"Code Projects/vault-cortex\")", + "type": "string", + "minLength": 1 + }, + "tag": { + "description": "Inline task tag, bare name without \"#\"; parent tags match children", + "type": "string", + "minLength": 1 + }, + "heading": { + "description": "Exact heading text or array of headings, OR-combined, case-sensitive (e.g. \"Active\" or [\"Active\", \"Up Next\"])", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + ] + }, + "path": { + "description": "Restrict to one note (vault-relative path ending \".md\", case-sensitive)", + "type": "string", + "minLength": 1 + }, + "top_level_only": { + "default": false, + "description": "When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items. Default false.", + "type": "boolean" + }, + "limit": { + "default": 50, + "description": "Max results (default 50); total always reports the full match count", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "sort_by": { + "default": "due", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "type": "string", + "enum": [ + "due", + "scheduled", + "start", + "created", + "done", + "priority", + "note_mtime", + "position" + ] + }, + "sort_direction": { + "description": "Sort direction. Default per field: \"asc\" for due/scheduled/priority/position, \"desc\" for start/created/done/note_mtime. Within a date cascade, each fallback uses its own default; an explicit value overrides all fields uniformly.", + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_memory_recall", + "title": "Memory Recall", + "description": "Recall memory entries about a topic — entry-granular hybrid (keyword + semantic) retrieval across ALL About Me/ files and ALL time. Returns every relevant dated entry sorted oldest-first, so the full evolution of a preference, opinion, or fact is visible — semantic matching finds early entries even when their phrasing differs from the query. Tuned for recall over precision: expect some marginal entries and judge relevance yourself when synthesizing an answer. Content-word queries (\"testing philosophy\", \"sustainable pacing\") rank best. A meta-framed query (\"opinions on testing\") that scores below the relevance threshold degrades to relaxed any-term keyword matching instead of returning nothing.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"testing philosophy\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory or vault_delete_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. file is applied before limit, so limit keeps the most relevant matches from that file. search_mode is \"hybrid\" when vector matching contributed, \"fts\" when the entries came from keyword matching alone — including the any-term fallback that rescues a would-be-empty result; reranked is true when the cross-encoder relevance cut was applied.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "description": "Topic to recall — natural language works best (semantic matching bridges phrasing drift across months); content words about the topic rank better than meta framing (\"testing philosophy\" over \"opinions on testing\")" + }, + "file": { + "description": "Optional: restrict to one memory file, name without .md (e.g. \"Opinions\"), in its exact letter case. Omit for cross-file recall — the default and usual choice.", + "type": "string", + "minLength": 1 + }, + "limit": { + "default": 50, + "description": "Cap on returned entries (default 50).", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "query" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_move_note", + "title": "Move Note", + "description": "Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Moving a note any other way can silently break its backlinks.\n\nExample: vault_move_note({ old_path: \"Inbox/Draft.md\", new_path: \"Inbox/Spec.md\" }) — pure rename.\nExample: vault_move_note({ old_path: \"Inbox/spec.md\", new_path: \"Inbox/Spec.md\" }) — case-only rename; works even where the filesystem treats both spellings as one file.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\" }) — move to another folder, updating links and the note's own relative links.\nExample: vault_move_note({ old_path: \"Inbox/Spec.md\", new_path: \"Projects/Spec.md\", prune_empty_folders: true }) — also remove \"Inbox\" if the move empties it.\n\nWhen to use: Renaming a note or relocating it to a different folder while keeping the link graph intact.\nPrefer this over vault_write_note + vault_delete_note, which would orphan every backlink. To only change a note's body or properties, use vault_patch_note or vault_update_properties. Protected paths (About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)) cannot be moved.\n\nParameters:\n- prune_empty_folders removes each parent folder of old_path that the move leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. An in-place rename or a move into a subfolder of the source prunes nothing. Pruning is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"destination exists: …\" — a note already lives at new_path; this tool never overwrites. Pick a free path or delete the existing note first.\n- \"note not found: …\" — old_path does not exist; verify it with vault_list_notes.\n- \"source and destination are the same path\" — old_path and new_path name the same note, so there is nothing to move.\n- \"cannot move protected path …\" / \"cannot move into protected path …\" — old_path or new_path sits under a protected folder.\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- \"path must end in …\" — both old_path and new_path must end in .md.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use vault-relative paths with no hidden (dot-prefixed) file or folder in them (notes cannot move from or into hidden paths, matching Obsidian).\n- \"concurrent write in progress\" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move.\n- \"backlink set did not stabilize\" — the vault was modified during the move and new backlink sources kept appearing across retries; nothing was written; retry the move.\n- An ordinary move that fails partway (rare: a permission or disk error) — no data is lost, and the error names what failed and the resulting state. The original is deleted last, after the destination and every backlink are written. If a backlink write failed: new_path exists and old_path is intact, so delete the partial new_path, then re-run the move. If the final delete failed: both paths exist, so delete old_path to finish.\n- A case-only rename that fails partway — the note is renamed in place first. If the rename failed: nothing was written. If a later link write failed: the note already lives at new_path and old_path is gone, so fix the remaining links in place (the error names the note whose update failed) instead of re-running the move.\n\nObsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed.\n\nReturns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten, including the moved note's own relative links), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of empty parent folders removed — 0 unless prune_empty_folders was set).", + "inputSchema": { + "type": "object", + "properties": { + "old_path": { + "type": "string", + "minLength": 1, + "description": "Current vault-relative path of the note to move (e.g. \"Inbox/Draft.md\"). Must end in .md. Use the exact letter case." + }, + "new_path": { + "type": "string", + "minLength": 1, + "description": "Destination vault-relative path (e.g. \"Projects/Spec.md\"). Must end in .md and must not already exist; parent folders are created as needed. Use the exact letter case of existing folders; a different case can create a second folder." + }, + "prune_empty_folders": { + "default": false, + "description": "When true, also remove parent folders of old_path that the move leaves empty. Default false.", + "type": "boolean" + } + }, + "required": [ + "old_path", + "new_path" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_patch_note", + "title": "Patch Note", + "description": "Surgical edits to a markdown note — append, prepend, replace, or insert content by heading. Frontmatter values are preserved; YAML formatting may be normalized to block style on first edit.\n\nExample: vault_patch_note({ path: \"TASKS.md\", operation: \"append\", heading: \"Active\", content: \"- [ ] New task\" })\n\nCross-section move (e.g. completing a task on a board):\n1. vault_read_note to get current content and verify exact text\n2. vault_patch_note({ path, operation: \"append\", heading: \"Done\", content: \"- [x] Task text\" }) to add at target\n3. vault_replace_in_note({ path, old_text: \"- [ ] Task text\", new_text: \"\" }) to remove from source (for a large multi-line block, prefer vault_delete_span); on error, re-read and retry until the source copy is gone\nAdd at the target before deleting from the source — the two writes are not atomic, so this order can briefly duplicate the moved block on a failure but never lose it.\n\nWhen to use: Modifying part of an existing note without overwriting the entire body.\nPrefer vault_write_note for creating new notes, or full rewrites (with overwrite: true). Prefer vault_replace_in_note for in-place text changes (typos, renaming) that stay in the same location.\n\nOperations:\n- append: add content at end of section (or end of file if no heading)\n- prepend: add content after heading line (or at the top of the body, below frontmatter, if no heading — how you add a leading callout). To start a new section above the note's current first heading, use insert_before on that heading, not a no-heading prepend.\n- replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set)\n- insert_before: insert content above the heading line (requires heading)\n\nHeading-targeted ops keep the matched heading and write content verbatim. No separator is added around the content — end it with a newline to leave a blank line after the inserted block.\n\nLimitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, the pre-existing content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF), so it includes its child headings. Empty headings (\"##\" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead.\n\nEditing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it).\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or use vault_replace_in_note to target by text content when headings share the same level\n- \"operation … requires a heading target\" — replace and insert_before need a heading\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"content begins with the heading … which would duplicate it\" — content's first line repeats the target heading; omit it (the matched heading is kept automatically)\n- \"section … has N child headings …\" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block. Inserting heading-level content (## New Section) changes the note's structure — future heading-targeted ops may resolve differently.\nTable rows: send only the data row (\"| cell1 | cell2 |\"), not the header or separator — duplicating them splits the table.\n\nReturns: Confirmation message — \"Applied to → \", where target is the matched heading (e.g. \"## Active\") or \"file body\" for a no-heading append/prepend. A no-heading prepend that nested existing content under an inserted heading adds a sentence naming the content's size and the call that would have avoided it.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Vault-relative path to the note, including the \".md\" extension (e.g. \"TASKS.md\", \"Projects/plan.md\"). Use the exact letter case." + }, + "operation": { + "type": "string", + "enum": [ + "append", + "prepend", + "replace", + "insert_before" + ], + "description": "append | prepend | replace | insert_before." + }, + "content": { + "type": "string", + "minLength": 1, + "description": "Markdown content to insert. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically)." + }, + "heading": { + "description": "Target heading text (case-sensitive exact match). Omit for a file-level append or prepend.", + "type": "string", + "minLength": 1 + }, + "heading_level": { + "description": "Heading level (1-6) for disambiguation when multiple headings share the same text", + "type": "integer", + "minimum": 1, + "maximum": 6 + }, + "include_children": { + "description": "When true, allows replace to overwrite a section that contains child headings. Without this, replace errors if children exist — preventing silent data loss.", + "type": "boolean" + } + }, + "required": [ + "path", + "operation", + "content" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_read_file", + "title": "Read File", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhat each file type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n\nraw: true switches a canvas or PDF to its other form:\n- Canvas: the exact JSON source (geometry, ids, colors — full fidelity) instead of the outline.\n- PDF: each page rendered and returned as an image block instead of extracted text, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n\nLine paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\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 (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON, so no outline can be built; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- an image that cannot be decoded (corrupt, empty, or not an image despite its extension) fails with the decoder's message, e.g. \"Input buffer contains unsupported image format\"; replace or re-export the file\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Vault-relative path to the file, including its extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\" — notes are read with vault_read_note. Use the exact letter case." + }, + "raw": { + "description": "Return the file's alternative form: JSON source for .canvas, page images for .pdf. Changes nothing for text formats, which already return their source. Rejected for images.", + "type": "boolean" + }, + "start_line": { + "description": "First line to return, 1-based (default 1).", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "limit": { + "description": "Maximum lines returned (default: all remaining).", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "path" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_read_note", + "title": "Read Note", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. Prefer vault_get_memory for About Me/ files (returns content without properties). To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note, and edit via vault_replace_in_note.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Vault-relative path to the note, including the \".md\" extension (e.g. \"About Me/Principles.md\"). Use the exact letter case." + }, + "properties_only": { + "description": "If true, returns parsed properties as JSON instead of full note content", + "type": "boolean" + }, + "outline": { + "description": "If true, returns { bytes, modified, leading_callout?, leading_content?, headings } as JSON instead of body content — a cheap structure fetch for large notes. headings: [{ level, text, bytes }]; leading_callout: { type, title, body } when the note has a top-of-file callout; leading_content: the rest of the body text above the first heading (callout lines excluded) when the note has any.", + "type": "boolean" + }, + "heading": { + "description": "Return only this section. Case-sensitive exact match.", + "type": "string", + "minLength": 1 + }, + "heading_level": { + "description": "Heading level (1-6) for disambiguation when multiple headings share the same text; only applies with heading", + "type": "integer", + "minimum": 1, + "maximum": 6 + }, + "start_line": { + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "limit": { + "description": "Maximum lines returned (default: all remaining).", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "path" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "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.", + "inputSchema": { + "type": "object", + "properties": { + "sort_by": { + "default": "modified", + "description": "Sort order (default \"modified\")", + "type": "string", + "enum": [ + "created", + "modified" + ] + }, + "limit": { + "default": 20, + "description": "Max results (default 20, no upper cap)", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_replace_in_note", + "title": "Replace in Note", + "description": "Find and replace text in a markdown note's body. Matches exact text (case-sensitive). Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only — properties must be edited via vault_update_properties or vault_write_note's properties parameter.\n\nExample: vault_replace_in_note({ path: \"Projects/plan.md\", old_text: \"TODO: write summary\", new_text: \"Summary complete.\" })\n\nWhen to use: Targeted text changes within a single location — fixing typos, updating values, renaming terms, or removing a short line (new_text=\"\"). Replaces text in place; does not move content across sections.\nTo delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text). To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span. To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text=\"\") — add-before-delete, so a failure duplicates the block instead of losing it.\n\nParameters:\n- old_text: include enough surrounding context to ensure uniqueness when the target text appears in multiple places. No regex — exact text only.\n- new_text: after a deletion (new_text=\"\"), every run of consecutive blank lines in the note's body is collapsed to a single blank line.\n- replace_all_occurrences: replacing only the first match is a safety default for when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements.\n\nErrors:\n- \"note not found\" — path does not exist; check vault_list_notes for valid paths\n- \"path must end in …\" — add the .md extension\n- \"text not found\" — old_text does not appear in the note body; verify exact text with vault_read_note\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; re-read the note and retry\n- \"new_text contains a control character\" — new_text includes a non-printable control byte; remove it before writing\n\nObsidian syntax: new_text is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block in replacement text.\n\nReturns: Confirmation message with replacement count (number of occurrences replaced).", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Vault-relative path to the note, including the \".md\" extension (e.g. \"Projects/plan.md\"). Use the exact letter case." + }, + "old_text": { + "type": "string", + "minLength": 1, + "description": "Exact text to find (case-sensitive). Matches in the body only — text inside frontmatter properties is not searched." + }, + "new_text": { + "type": "string", + "description": "Replacement text. Empty string (\"\") deletes the matched text." + }, + "replace_all_occurrences": { + "default": false, + "description": "Replace all occurrences (default: false — replaces first occurrence only)", + "type": "boolean" + } + }, + "required": [ + "path", + "old_text", + "new_text" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_replace_span", + "title": "Replace Span", + "description": "Replace a contiguous block of whole lines in a note's body with new content, identified by short anchor substrings instead of the block's full text. Each anchor locates a full line — the entire line is selected, not just the matching substring. Same anchor semantics as vault_delete_span. Case-sensitive matching. Properties are preserved; YAML formatting may be normalized to block style on first edit. Operates on the body only.\n\nExample: vault_replace_span({ path: \"Tracker.md\", start_anchor: \"| 2024-03-02 | Acme\", content: \"| 2024-03-02 | Acme Corp | Updated |\" }) — replaces the one table row whose line contains that fragment.\nExample: vault_replace_span({ path: \"Notes/Plan.md\", start_anchor: \"> [!warning] Stale\", end_anchor: \"remove after launch\", content: \"> [!info] Current\\n> Updated for v2.\" }) — replaces the callout block with a new one.\n\nWhen to use: Replacing a block you have already read — a table row, callout, or run of list items — where reproducing it exactly as old_text would be error-prone. Pick a short, unique fragment of the first line for start_anchor and, for a multi-line block, the last line for end_anchor.\nPrefer vault_replace_in_note for small in-place text changes (typos, renaming). Prefer vault_delete_span when removing without replacement.\n\nParameters:\n- start_anchor + end_anchor define a line range, not a text range (never cuts mid-line).\n- end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced.\n- content: a trailing newline adds a blank line after the new block.\n- first_match applies to both anchors independently.\n- After the replacement, every run of consecutive blank lines in the note's body — including runs within the replacement text — is collapsed to a single blank line.\n\nErrors:\n- \"note not found\" — verify path with vault_list_notes\n- \"path must end in …\" — add the .md extension\n- \"start anchor not found\" / \"end anchor not found\" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note\n- \"ambiguous start anchor …\" / \"ambiguous end anchor …\" — the anchor matches multiple lines; use a longer fragment or set first_match: true\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; re-read the note and retry\n- \"content contains a control character\" — content includes a non-printable control byte; remove it before writing\n\nObsidian syntax: content is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag, [[ = wikilink, %% = comment block.\n\nReturns: Confirmation message \"Replaced lines with lines in \" — N counts the lines the span covered, M the lines content supplied.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Vault-relative path to the note, including the \".md\" extension (e.g. \"Tracker.md\", \"Notes/Plan.md\"). Use the exact letter case." + }, + "start_anchor": { + "type": "string", + "minLength": 1, + "description": "Short, unique substring that identifies the first line of the block (case-sensitive). The entire line is selected, not just the substring. Pick a brief fragment — do not paste the whole block." + }, + "end_anchor": { + "description": "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to replace just the single line containing start_anchor.", + "type": "string", + "minLength": 1 + }, + "content": { + "type": "string", + "minLength": 1, + "description": "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty." + }, + "first_match": { + "default": false, + "description": "If an anchor matches more than one line, use the first match instead of erroring (default: false — ambiguity is an error).", + "type": "boolean" + } + }, + "required": [ + "path", + "start_anchor", + "content" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_search", + "title": "Search Notes", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "description": "Search query text" + }, + "filters": { + "description": "Optional structured filters", + "type": "object", + "properties": { + "folder": { + "description": "Restrict to a folder (e.g. \"Projects\")", + "type": "string", + "minLength": 1 + }, + "tags": { + "description": "Require all listed tags (AND — every tag must be present)", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "related": { + "description": "Require all listed related links", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "type": { + "description": "Match the frontmatter type field (exact match, e.g. \"person\", \"meeting\")", + "type": "string", + "minLength": 1 + }, + "properties": { + "description": "Match arbitrary frontmatter properties by key-value (e.g. { status: \"active\", priority: 1 })", + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + }, + "created": { + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", + "type": "object", + "properties": { + "before": { + "description": "Exclusive upper bound (YYYY-MM-DD) — strictly earlier dates", + "type": "string", + "minLength": 1 + }, + "on": { + "description": "Exact date match (YYYY-MM-DD)", + "type": "string", + "minLength": 1 + }, + "after": { + "description": "Exclusive lower bound (YYYY-MM-DD) — strictly later dates", + "type": "string", + "minLength": 1 + } + } + }, + "modified": { + "description": "Modified date bounds (YYYY-MM-DD) on filesystem modified time, server-local day boundaries", + "type": "object", + "properties": { + "before": { + "description": "Exclusive upper bound (YYYY-MM-DD) — strictly earlier dates", + "type": "string", + "minLength": 1 + }, + "on": { + "description": "Exact date match (YYYY-MM-DD)", + "type": "string", + "minLength": 1 + }, + "after": { + "description": "Exclusive lower bound (YYYY-MM-DD) — strictly later dates", + "type": "string", + "minLength": 1 + } + } + } + } + }, + "limit": { + "default": 20, + "description": "Max results (default 20)", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "snippet_tokens": { + "default": 30, + "description": "Snippet length in words (default 30)", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "include_leading_callout": { + "default": false, + "description": "If true, include each result's leading callout. Off by default to keep results lean.", + "type": "boolean" + } + }, + "required": [ + "query" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_search_by_folder", + "title": "Search by Folder", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", + "inputSchema": { + "type": "object", + "properties": { + "folder": { + "type": "string", + "minLength": 1, + "description": "Folder path (e.g. \"Projects\", \"About Me\")" + }, + "recursive": { + "default": true, + "description": "Include subfolders (default: true); false lists only the folder's top level", + "type": "boolean" + }, + "limit": { + "default": 20, + "description": "Max results (default 20)", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "folder" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "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). Matching is exact and case-sensitive; an unknown key or unmatched value returns an empty array, not an error.\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 + value are both exact and case-sensitive — no partial matching or globbing. Values are compared as text: pass a number as its digits and a checkbox as \"1\" or \"0\" (true is stored as 1, false as 0).\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 (default 20) applies after sorting. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\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.", + "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." + }, + "value": { + "type": "string", + "minLength": 1, + "description": "Value to match (exact, case-sensitive, e.g. \"active\", \"session-log\"). Use vault_list_property_values to discover valid values for a key." + }, + "folder": { + "description": "Restrict to a folder (e.g. \"Projects\")", + "type": "string", + "minLength": 1 + }, + "limit": { + "default": 20, + "description": "Max results (default 20)", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "key", + "value" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "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.", + "inputSchema": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "minLength": 1, + "description": "Tag name without \"#\" prefix (e.g. \"project\", \"session-log\"). Hierarchical tags use \"/\" separators (e.g. \"project/vault-cortex\")." + }, + "exact": { + "default": false, + "description": "Exact match only (default: false, prefix match)", + "type": "boolean" + } + }, + "required": [ + "tag" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_update_memory", + "title": "Update Memory", + "description": "Append a dated entry to a section of a memory file in About Me/. The server prefixes the date automatically (\"- **YYYY-MM-DD**: entry text\") and inserts newest-first by default. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare `entry-policy: living` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation.\n\nExample: vault_update_memory({ file: \"Opinions\", section: \"Code patterns (newest first)\", entry: \"Prefer immutable data structures\" })\n\nWhen to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped.\nPrefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get \"(newest first)\" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note). A new section name nearly identical to an existing heading (an HTML-entity slip, typo, or spacing variation) is rejected, so a mistyped name cannot silently fragment the file — names differing only in digits (e.g. \"2025\" vs \"2026\") are distinct.\n\nParameters:\n- options.position — \"top\" (default, newest-first) inserts above existing entries; \"bottom\" appends below them.\n\nObsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional.\n\nErrors:\n- \"refusing memory write: … would shrink content\" — safety guard: the write would leave the file at under half its size, which happens when the file holds content a rewrite cannot keep (for example long YAML comments in its properties). A retry returns the same refusal until a manual edit removes that content; inspect the file with vault_read_note to find it.\n- \"entry must be a single line\" — memory entries are single dated bullets; collapse newlines or append multiple entries.\n- \"section must be a single line\" — section names become H2 headings; remove line breaks.\n- \"date must be a real ISO calendar date\" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. \"2026-07-02\"), not a timestamp.\n- \"entry contains a control character\" / \"section contains a control character\" — the value includes a non-printable control byte; remove it before writing.\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — use a bare file name: no folder or slash, and no leading dot (that would create a hidden file, invisible in Obsidian and to every listing).\n- \"section not created: … is nearly identical to existing section …\" — near-duplicate guard; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section.\n\nReturns: Confirmation message (notes when an identical entry already existed and nothing was written).", + "inputSchema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "minLength": 1, + "description": "Memory file name without .md (e.g. \"Principles\"). Use the exact letter case; a different case can create a second file." + }, + "section": { + "type": "string", + "minLength": 1, + "description": "H2 section heading (e.g. \"Decision heuristics (newest first)\"). Matched case-insensitively, with or without the \"(newest first)\" suffix." + }, + "entry": { + "type": "string", + "minLength": 1, + "description": "Raw entry text — a single line (newlines are rejected); the server prepends \"- **YYYY-MM-DD**: \" automatically. Do not include the date or bullet prefix." + }, + "options": { + "description": "Optional date and position overrides", + "type": "object", + "properties": { + "date": { + "description": "ISO YYYY-MM-DD date (defaults to today, server timezone)", + "type": "string", + "minLength": 1 + }, + "position": { + "default": "top", + "description": "Insert position (default \"top\" = newest first)", + "type": "string", + "enum": [ + "top", + "bottom" + ] + } + } + } + }, + "required": [ + "file", + "section", + "entry" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_update_properties", + "title": "Update Properties", + "description": "Update a note's frontmatter properties via shallow merge — new keys added, matching keys overwritten, null deletes a key, unmentioned keys preserved. Body is never modified.\n\nExample: vault_update_properties({ path: \"Projects/todo.md\", properties: { status: \"active\", draft: null } })\n\nWhen to use: Changing tags, status, type, or any property without reading/rewriting the full note body.\nPrefer vault_write_note when creating a new note, or replacing the body (with overwrite: true). Read current properties first with vault_read_note({ properties_only: true }) — arrays are replaced entirely, not appended to.\n\nErrors:\n- \"note not found\" — path does not exist; create the note first with vault_write_note\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; re-read the note and retry\n\nObsidian syntax: Use arrays for multi-value fields (tags: [a, b]), quote wikilinks (\"[[Note]]\"), keep types consistent (mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Vault-relative path to the note, including the \".md\" extension. Use the exact letter case." + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": {}, + "description": "Properties to merge; a null value deletes that key." + } + }, + "required": [ + "path", + "properties" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_update_task", + "title": "Update Task", + "description": "Update a task's status, priority, description, dates, dependencies, block_id, checklist items, or heading placement in one call. Any combination of these can change together — every field passed is written in a single edit.\n\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"done\" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", recurrence: \"every week\" }) — make a task recurring (null removes the rule)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Done\" }) — move a task to a different heading (lands at the top of the lane by default)\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", description: \"Updated task name\", due: \"2026-10-01\", scheduled: null }) — change the description, set one date, and clear another\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", status: \"in_progress\", add_subtasks: [\"Design\", \"Implement\", \"Test\"] }) — start working and add checklist stages\nExample: vault_update_task({ path: \"TASKS.md\", line: 42, assign_block_id: \"my-task\" }) — add a block_id to a task that lacks one\nExample: vault_update_task({ path: \"TASKS.md\", block_id: \"my-task\", heading: \"Active\", position: 3 }) — move to the 3rd position in a lane\n\nWhen to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane. Use vault_list_tasks first to get identification fields (path + block_id or line). For creating a new task, use vault_create_task instead.\n\nParameters:\n- Exactly one of block_id or line is required to identify the task.\n- At least one change is required. Clearing is always explicit null — omitting a field leaves it untouched.\n- status manages the checkbox and the done/cancelled dates.\n Kanban: \"done\" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent.\n Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's \"next line\" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id.\n Delete (🏁 delete / [onCompletion:: delete]): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with \"next line\", children transfer to the spawn. Result carries on_completion_applied: \"delete\".\n- recurrence: a rule ending \"when done\" bases the next occurrence on the completion day. When set together with status \"done\", the new rule governs the spawn. Setting recurrence to null while completing removes the rule and completes without spawning.\n- on_completion: passed together with status, the submitted value governs the delete decision — \"keep\" while completing a \"delete\" task prevents the deletion.\n- position: applies to a heading move or an auto-done-lane move. Without a heading, it triggers a same-lane reorder to the given position; omitting position performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.\n\nErrors:\n- \"note not found\" — path does not exist\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- \"exactly one of blockId or line is required\" / \"blockId and line are mutually exclusive\" — pass exactly one of block_id or line\n- \"blockId ... not found\" — no task line in the note ends with ^block_id\n- \"blockId ... is inside a fenced code block or comment\" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence\n- \"no task at line N\" — line doesn't contain a task checkbox\n- \"line N is inside a fenced code block or comment\" — the line is inside a fenced code block or %% %% comment; target a line outside the fence\n- \"checkbox \"[c]\" is a NON_TASK status\" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server\n- \"no checkbox symbol for status ...\" — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server\n- \"at least one mutation\" — no change params provided\n- \"cannot move a sub-task to a heading\" — explicit heading on a task nested under another task (depth > 0 in vault_list_tasks)\n- \"cannot reposition a sub-task\" — explicit position on a sub-task (sub-tasks move with their parent)\n- \"cannot reorder a task that sits above the first heading\" — position without a heading on a task before the first section heading\n- \"cannot reorder within \"X\" — the heading appears N times\" — same-lane reorder on a card whose heading name is duplicated in the note; rename one section to make it unique\n- \"cannot place at position N under \"X\" — the heading appears N times\" — cross-lane move with an integer position to a heading name that appears more than once; rename one section to make it unique\n- \"heading \"X\" not found; available: ...\" — target heading doesn't exist; the error lists the note's headings\n- \"multiple done lanes detected\" — status \"done\" on a Kanban board with more than one **Complete**-marked lane; pass heading to pick the lane\n- \"no done lane detected\" — status \"done\" on a Kanban board with no **Complete** marker and no \"Done\" heading; pass heading explicitly\n- \"blockId ... already exists\" / \"blockId ... contains invalid characters\" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+\n- \"invalid date\" — a date param fails calendar validation\n- \"description cannot be empty\" / \"addSubtasks cannot contain an empty item\" — whitespace-only description or checklist item\n- \"description must be a single line\" / \"addSubtasks items must be a single line\" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads\n- \"taskId ... contains invalid characters\" / \"dependsOn entry ... contains invalid characters\" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar)\n- \"unrecognized recurrence rule ...\" — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n\nObsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or add_subtasks text (an emoji field like \"🔁 every week\", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported.\n\nReturns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is \"delete\", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as \"field: before → after\", with \"(none)\" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds \"next_occurrence: (none) → line N\"); advisories (omitted when there are none) lists one sentence for each: a stored line that parses back differently than submitted (see Obsidian syntax above), a duplicate tag removed from the line, or a completed recurring task whose rule yields no next occurrence (unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always \"delete\".", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Vault-relative path to the note containing the task (must end in \".md\"). Use the exact letter case." + }, + "block_id": { + "description": "Stable task identifier — the ^block-id at the end of the task line, without the ^. Preferred over line.", + "type": "string", + "minLength": 1 + }, + "line": { + "description": "1-based line number from vault_list_tasks. Fragile if the file changed since the query.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "status": { + "description": "Target status. \"done\" appends the ✅ date; \"cancelled\" appends the ❌ date.", + "type": "string", + "enum": [ + "todo", + "in_progress", + "done", + "cancelled" + ] + }, + "priority": { + "description": "Priority signifier to set, or null to remove it.", + "anyOf": [ + { + "type": "string", + "enum": [ + "highest", + "high", + "medium", + "low", + "lowest" + ] + }, + { + "type": "null" + } + ] + }, + "recurrence": { + "description": "Tasks plugin 🔁 rule in natural language (e.g. \"every week\", \"every month on the 15th\", \"every 2 weeks when done\") to set, or null to remove it. Completing the task spawns its next occurrence.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "on_completion": { + "description": "Tasks plugin 🏁 onCompletion action to set, or null to remove it. \"delete\" removes the task line on completion; \"keep\" leaves it in place (which is also the behavior when no 🏁 field exists on the task). Omitting this parameter leaves the field unchanged.", + "anyOf": [ + { + "type": "string", + "enum": [ + "delete", + "keep" + ] + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "New task description text. Replaces the existing description; metadata fields and block_id are preserved.", + "type": "string", + "minLength": 1 + }, + "due": { + "description": "Due date (YYYY-MM-DD) to set, or null to clear.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "scheduled": { + "description": "Scheduled date (YYYY-MM-DD) to set, or null to clear.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "start": { + "description": "Start date (YYYY-MM-DD) to set, or null to clear.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "created": { + "description": "Created date (YYYY-MM-DD) to set or clear. Typically auto-stamped; use for corrections.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "task_id": { + "description": "Tasks plugin 🆔 identifier to set, or null to clear.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "depends_on": { + "description": "Tasks plugin ⛔ dependency IDs to set (non-empty), or null to clear.", + "anyOf": [ + { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + { + "type": "null" + } + ] + }, + "add_subtasks": { + "description": "Checklist items to append, one indented todo line each, under the task's existing items — never replaces them. Can be combined with any other change; not appended when the same call removes the task (on_completion delete). For full sub-tasks with metadata, use vault_create_task with parent_block_id.", + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "assign_block_id": { + "description": "Add or replace the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note.", + "type": "string", + "minLength": 1 + }, + "heading": { + "description": "Target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks.", + "type": "string", + "minLength": 1 + }, + "position": { + "description": "Where within the target heading the task lands. \"top\" or \"bottom\" for the extremes; an integer (1-based) for an exact position among the lane's top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike \"bottom\", which appends after any non-task text at the end of the section). Defaults to \"top\" on heading moves.", + "anyOf": [ + { + "type": "string", + "enum": [ + "top", + "bottom" + ] + }, + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + ] + }, + "format": { + "description": "Field format for new metadata. Default: auto-detected from .obsidian/ config, falling back to emoji.", + "type": "string", + "enum": [ + "emoji", + "dataview" + ] + } + }, + "required": [ + "path" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "vault_write_note", + "title": "Write Note", + "description": "Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content: existing content will be lost unless you include it in body, so do not use this tool for surgical edits to large files. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved); overwriting without properties keeps the existing property values.\n\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"# Notes\\n\\nProject notes here.\", properties: { tags: [\"project\"], type: \"project\" } })\nExample: vault_write_note({ path: \"Projects/notes.md\", body: \"Updated content.\", overwrite: true })\n\nWhen to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body.\nPrefer vault_update_properties for property-only edits (no body round-trip).\nPrefer vault_update_memory for appending dated entries to About Me/ memory files.\n\nErrors:\n- \"note already exists\" — a note already lives at this path; set overwrite: true to replace it, or use vault_patch_note / vault_replace_in_note for partial edits\n- \"path must end in …\" — add the .md extension\n- \"cannot write note …: that path is not a file\" — a folder already has this name; choose another path\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; re-read the note and retry\n- \"body contains a control character\" — body includes a non-printable control byte; remove it before writing\n\nObsidian syntax: Body is Obsidian Flavored Markdown (no escaping applied). Watch for: #word = tag (escape with \\#), [[ = wikilink, %% = comment block. In properties: quote wikilink values (\"[[Note]]\"), use YAML lists for tags, keep property types consistent (string/number/list mismatches cause silent query failures).\n\nReturns: Confirmation message.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Vault-relative path including the \".md\" extension (e.g. \"Projects/notes.md\"). Parent folders are created as needed. Use the exact letter case; a different case can create a duplicate note or folder." + }, + "body": { + "type": "string", + "description": "Markdown body content — do not include frontmatter fences (---); use the properties parameter instead." + }, + "properties": { + "description": "Optional properties to merge; a null value deletes that key.", + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": {} + }, + "overwrite": { + "default": false, + "description": "Allow overwriting an existing note (default: false — errors if file exists).", + "type": "boolean" + } + }, + "required": [ + "path", + "body" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + } + ], + "prompts": [ + { + "name": "daily-review", + "title": "Daily review & reconciliation", + "description": "Reconcile a day — daily note, vault-wide task status (due/overdue, scheduled), modified notes, and links — surface what happened, what's open, and durable facts worth saving to About Me/ memory.", + "arguments": [ + { + "name": "date", + "description": "Day to review in YYYY-MM-DD format (defaults to today)", + "required": false + }, + { + "name": "max_chars", + "description": "Optional cap on embedded content length (characters); omit for full content", + "required": false + } + ] + }, + { + "name": "memory-review", + "title": "Reflect on memory (read as an evolution)", + "description": "Reflect on the About Me/ memory layer — review its structure and scopes, read dated entries as a timeline, surface scope-fit issues and coverage gaps, and propose append-only updates. Never prunes entries for being old, except expired entries in files marked entry-policy: living.", + "arguments": [ + { + "name": "file", + "description": "Memory file to review (e.g. one from About Me/); omit to review all", + "required": false + }, + { + "name": "max_chars", + "description": "Optional cap on embedded content length (characters); omit for full content", + "required": false + } + ] + }, + { + "name": "vault-orientation", + "title": "Orient to this vault", + "description": "Survey this vault's structure and health — stats, folders, tags, properties (with adoption rates), orphans, recent notes, and the About Me/ memory layer." + } + ] +} 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 c2bfa2cf8..713fa222b 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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -72,7 +72,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", "inputSchema": { "type": "object", "properties": { @@ -97,7 +97,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when the file lists new entries at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. Designed for reconciliation consumers that know a boundary date and need deterministic chronological coverage without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" — section was provided without file; pass both or just file\n- \"on_or_after requires a file\" — on_or_after needs a file to scope the filter\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" — a dot-prefixed name would be a hidden file; memory files are always visible notes\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. It gives complete coverage from a known date without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would be a hidden file; memory files are always visible notes)\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", "inputSchema": { "type": "object", "properties": { @@ -132,7 +132,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF) readable via vault_read_file; !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image file is still cheap to read.\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF) readable via vault_read_file. When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image is still cheap to read.\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -160,17 +160,18 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nParameters:\n- folder: folder path filter (e.g. \"attachments\" or \"Projects/media\"), searched recursively; omit for the whole vault. This tool reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n- extensions: restrict to these extensions — case-insensitive, with or without the leading dot (\".png\" and \"png\" both work)\n- limit: maximum entries returned (default 50). extension_counts and total always reflect the full filtered set, not just the returned page.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Files of supported types are readable via vault_read_file.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: Reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Only supported types are readable via vault_read_file.", "inputSchema": { "type": "object", "properties": { "folder": { - "description": "Folder path to search recursively (e.g. \"attachments\"). Omit to list the whole vault.", + "description": "Folder path to search recursively (e.g. \"attachments\" or \"Projects/media\"). Omit to list the whole vault.", "type": "string", "minLength": 1 }, "extensions": { "description": "Only include these extensions, case-insensitive, leading dot optional (e.g. [\".png\", \"jpg\"]).", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -200,7 +201,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with their H1/H2 heading structure, per-section entry counts, and each file's leading callout (by convention a \"Scope of this file\" block describing what belongs in it). An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy. leading_callout is the file's top-of-file callout ({ type, title, body }), or null.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -314,7 +315,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -333,13 +334,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -497,6 +498,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -556,7 +558,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -593,7 +595,7 @@ { "name": "vault_memory_recall", "title": "Memory Recall", - "description": "Recall memory entries about a topic — entry-granular keyword retrieval across ALL About Me/ files and ALL time. Returns every matching dated entry sorted oldest-first, so the evolution of a preference, opinion, or fact reads in order. Matching is stemmed keywords only (semantic matching is off — EMBEDDING_ENABLED=false), and phrasing drifts across months, so re-query with synonyms to cover a topic fully (e.g. \"pacing\", then \"recovery\", then \"sustainable hours\"). A multi-word query whose terms never co-occur in one entry degrades to any-term matching before returning empty.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"opinions on testing\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range. search_mode is always \"fts\" and reranked always false in keyword-only mode.", + "description": "Recall memory entries about a topic — entry-granular keyword retrieval across ALL About Me/ files and ALL time. Returns every matching dated entry sorted oldest-first, so the evolution of a preference, opinion, or fact reads in order. Matching is stemmed keywords only (semantic matching is off — EMBEDDING_ENABLED=false), and phrasing drifts across months, so re-query with synonyms to cover a topic fully (e.g. \"pacing\", then \"recovery\", then \"sustainable hours\"). A multi-word query whose terms never co-occur in one entry degrades to any-term matching before returning empty.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"testing philosophy\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. file is applied before limit, so limit keeps the most relevant matches from that file. search_mode is always \"fts\" and reranked always false in keyword-only mode.", "inputSchema": { "type": "object", "properties": { @@ -609,7 +611,7 @@ }, "limit": { "default": 50, - "description": "Cap on returned entries (default 50). When more match, the least-relevant are dropped and truncated=true — never a date range.", + "description": "Cap on returned entries (default 50).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -633,7 +635,7 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\", raw: true }) — the canvas's exact JSON source\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\nExample: vault_read_file({ path: \"papers/research.pdf\", raw: true }) — each page rendered as an image block\n\nWhat each type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. Set raw: true for the exact JSON source instead (geometry, ids, colors — full fidelity).\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved. Set raw: true for page images instead — each page rendered and returned as an image block, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n- Line paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhat each file type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n\nraw: true switches a canvas or PDF to its other form:\n- Canvas: the exact JSON source (geometry, ids, colors — full fidelity) instead of the outline.\n- PDF: each page rendered and returned as an image block instead of extracted text, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n\nLine paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\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 (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON, so no outline can be built; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- an image that cannot be decoded (corrupt, empty, or not an image despite its extension) fails with the decoder's message, e.g. \"Input buffer contains unsupported image format\"; replace or re-export the file\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", "inputSchema": { "type": "object", "properties": { @@ -677,7 +679,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. Prefer vault_get_memory for About Me/ files (returns content without properties). To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. Prefer vault_get_memory for About Me/ files (returns content without properties). To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", "inputSchema": { "type": "object", "properties": { @@ -695,7 +697,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -706,13 +708,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -772,17 +774,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -834,7 +836,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -886,14 +888,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -915,7 +917,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -926,7 +928,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -1001,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "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 b2229e375..b973e8d48 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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -73,7 +73,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", "inputSchema": { "type": "object", "properties": { @@ -98,7 +98,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when the file lists new entries at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. Designed for reconciliation consumers that know a boundary date and need deterministic chronological coverage without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" — section was provided without file; pass both or just file\n- \"on_or_after requires a file\" — on_or_after needs a file to scope the filter\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" — a dot-prefixed name would be a hidden file; memory files are always visible notes\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. It gives complete coverage from a known date without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would be a hidden file; memory files are always visible notes)\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", "inputSchema": { "type": "object", "properties": { @@ -133,7 +133,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF); !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links).\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF). When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -161,7 +161,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with their H1/H2 heading structure, per-section entry counts, and each file's leading callout (by convention a \"Scope of this file\" block describing what belongs in it). An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy. leading_callout is the file's top-of-file callout ({ type, title, body }), or null.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -275,7 +275,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -294,13 +294,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -458,6 +458,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -517,7 +518,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -554,7 +555,7 @@ { "name": "vault_memory_recall", "title": "Memory Recall", - "description": "Recall memory entries about a topic — entry-granular keyword retrieval across ALL About Me/ files and ALL time. Returns every matching dated entry sorted oldest-first, so the evolution of a preference, opinion, or fact reads in order. Matching is stemmed keywords only (semantic matching is off — EMBEDDING_ENABLED=false), and phrasing drifts across months, so re-query with synonyms to cover a topic fully (e.g. \"pacing\", then \"recovery\", then \"sustainable hours\"). A multi-word query whose terms never co-occur in one entry degrades to any-term matching before returning empty.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"opinions on testing\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range. search_mode is always \"fts\" and reranked always false in keyword-only mode.", + "description": "Recall memory entries about a topic — entry-granular keyword retrieval across ALL About Me/ files and ALL time. Returns every matching dated entry sorted oldest-first, so the evolution of a preference, opinion, or fact reads in order. Matching is stemmed keywords only (semantic matching is off — EMBEDDING_ENABLED=false), and phrasing drifts across months, so re-query with synonyms to cover a topic fully (e.g. \"pacing\", then \"recovery\", then \"sustainable hours\"). A multi-word query whose terms never co-occur in one entry degrades to any-term matching before returning empty.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"testing philosophy\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. file is applied before limit, so limit keeps the most relevant matches from that file. search_mode is always \"fts\" and reranked always false in keyword-only mode.", "inputSchema": { "type": "object", "properties": { @@ -570,7 +571,7 @@ }, "limit": { "default": 50, - "description": "Cap on returned entries (default 50). When more match, the least-relevant are dropped and truncated=true — never a date range.", + "description": "Cap on returned entries (default 50).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -594,7 +595,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. Prefer vault_get_memory for About Me/ files (returns content without properties). To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. Prefer vault_get_memory for About Me/ files (returns content without properties). To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", "inputSchema": { "type": "object", "properties": { @@ -612,7 +613,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -623,13 +624,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -689,17 +690,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -751,7 +752,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -803,14 +804,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -832,7 +833,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -843,7 +844,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -918,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "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 6a02ca1b3..eb0583b1a 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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -72,7 +72,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", "inputSchema": { "type": "object", "properties": { @@ -97,7 +97,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when the file lists new entries at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. Designed for reconciliation consumers that know a boundary date and need deterministic chronological coverage without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" — section was provided without file; pass both or just file\n- \"on_or_after requires a file\" — on_or_after needs a file to scope the filter\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" — a dot-prefixed name would be a hidden file; memory files are always visible notes\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. It gives complete coverage from a known date without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would be a hidden file; memory files are always visible notes)\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", "inputSchema": { "type": "object", "properties": { @@ -132,7 +132,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF); !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links).\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF). When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -160,7 +160,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with their H1/H2 heading structure, per-section entry counts, and each file's leading callout (by convention a \"Scope of this file\" block describing what belongs in it). An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy. leading_callout is the file's top-of-file callout ({ type, title, body }), or null.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -274,7 +274,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -293,13 +293,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -457,6 +457,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -516,7 +517,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -553,7 +554,7 @@ { "name": "vault_memory_recall", "title": "Memory Recall", - "description": "Recall memory entries about a topic — entry-granular hybrid (keyword + semantic) retrieval across ALL About Me/ files and ALL time. Returns every relevant dated entry sorted oldest-first, so the full evolution of a preference, opinion, or fact is visible — semantic matching finds early entries even when their phrasing differs from the query. Tuned for recall over precision: expect some marginal entries and judge relevance yourself when synthesizing an answer. Content-word queries (\"testing philosophy\", \"sustainable pacing\") rank best; a meta-framed query (\"opinions on testing\") whose relevance cut would come back empty degrades to relaxed any-term keyword matching instead of returning nothing.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"opinions on testing\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. search_mode is \"hybrid\" when vector matching contributed, \"fts\" when the entries came from keyword matching alone — including the any-term fallback that rescues a would-be-empty result; reranked is true when the cross-encoder relevance cut was applied.", + "description": "Recall memory entries about a topic — entry-granular hybrid (keyword + semantic) retrieval across ALL About Me/ files and ALL time. Returns every relevant dated entry sorted oldest-first, so the full evolution of a preference, opinion, or fact is visible — semantic matching finds early entries even when their phrasing differs from the query. Tuned for recall over precision: expect some marginal entries and judge relevance yourself when synthesizing an answer. Content-word queries (\"testing philosophy\", \"sustainable pacing\") rank best. A meta-framed query (\"opinions on testing\") that scores below the relevance threshold degrades to relaxed any-term keyword matching instead of returning nothing.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"testing philosophy\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. file is applied before limit, so limit keeps the most relevant matches from that file. search_mode is \"hybrid\" when vector matching contributed, \"fts\" when the entries came from keyword matching alone — including the any-term fallback that rescues a would-be-empty result; reranked is true when the cross-encoder relevance cut was applied.", "inputSchema": { "type": "object", "properties": { @@ -569,7 +570,7 @@ }, "limit": { "default": 50, - "description": "Cap on returned entries (default 50). When more match, the least-relevant are dropped and truncated=true — never a date range.", + "description": "Cap on returned entries (default 50).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -593,7 +594,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. Prefer vault_get_memory for About Me/ files (returns content without properties). To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. Prefer vault_get_memory for About Me/ files (returns content without properties). To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", "inputSchema": { "type": "object", "properties": { @@ -611,7 +612,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -622,13 +623,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -688,17 +689,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -750,7 +751,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -802,14 +803,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -831,7 +832,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -842,7 +843,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -917,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "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 b80882fce..22e04af5a 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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -73,7 +73,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", "inputSchema": { "type": "object", "properties": { @@ -98,7 +98,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF) readable via vault_read_file; !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image file is still cheap to read.\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF) readable via vault_read_file. When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image is still cheap to read.\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -126,17 +126,18 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nParameters:\n- folder: folder path filter (e.g. \"attachments\" or \"Projects/media\"), searched recursively; omit for the whole vault. This tool reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n- extensions: restrict to these extensions — case-insensitive, with or without the leading dot (\".png\" and \"png\" both work)\n- limit: maximum entries returned (default 50). extension_counts and total always reflect the full filtered set, not just the returned page.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Files of supported types are readable via vault_read_file.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: Reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Only supported types are readable via vault_read_file.", "inputSchema": { "type": "object", "properties": { "folder": { - "description": "Folder path to search recursively (e.g. \"attachments\"). Omit to list the whole vault.", + "description": "Folder path to search recursively (e.g. \"attachments\" or \"Projects/media\"). Omit to list the whole vault.", "type": "string", "minLength": 1 }, "extensions": { "description": "Only include these extensions, case-insensitive, leading dot optional (e.g. [\".png\", \"jpg\"]).", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -261,7 +262,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -280,13 +281,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -444,6 +445,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -503,7 +505,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -540,7 +542,7 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\", raw: true }) — the canvas's exact JSON source\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\nExample: vault_read_file({ path: \"papers/research.pdf\", raw: true }) — each page rendered as an image block\n\nWhat each type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. Set raw: true for the exact JSON source instead (geometry, ids, colors — full fidelity).\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved. Set raw: true for page images instead — each page rendered and returned as an image block, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n- Line paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhat each file type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n\nraw: true switches a canvas or PDF to its other form:\n- Canvas: the exact JSON source (geometry, ids, colors — full fidelity) instead of the outline.\n- PDF: each page rendered and returned as an image block instead of extracted text, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n\nLine paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\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 (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON, so no outline can be built; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- an image that cannot be decoded (corrupt, empty, or not an image despite its extension) fails with the decoder's message, e.g. \"Input buffer contains unsupported image format\"; replace or re-export the file\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", "inputSchema": { "type": "object", "properties": { @@ -584,7 +586,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", "inputSchema": { "type": "object", "properties": { @@ -602,7 +604,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -613,13 +615,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -679,17 +681,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -741,7 +743,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -793,14 +795,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -822,7 +824,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -833,7 +835,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -908,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "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 581e20559..f1a7b66cc 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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -74,7 +74,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", "inputSchema": { "type": "object", "properties": { @@ -99,7 +99,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF); !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links).\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF). When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -222,7 +222,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -241,13 +241,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -405,6 +405,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -464,7 +465,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -501,7 +502,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", "inputSchema": { "type": "object", "properties": { @@ -519,7 +520,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -530,13 +531,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -596,17 +597,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Full-text search across all vault notes, ranked by relevance. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -658,7 +659,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -710,14 +711,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -739,7 +740,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -750,7 +751,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -825,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "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 d4230f63b..83d5f46db 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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -73,7 +73,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", "inputSchema": { "type": "object", "properties": { @@ -98,7 +98,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF); !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links).\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF). When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links).\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -221,7 +221,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -240,13 +240,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -404,6 +404,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -463,7 +464,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -500,7 +501,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", "inputSchema": { "type": "object", "properties": { @@ -518,7 +519,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -529,13 +530,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -595,17 +596,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -657,7 +658,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -709,14 +710,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -738,7 +739,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -749,7 +750,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -824,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "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 43e115b43..d1302973f 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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -72,7 +72,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", "inputSchema": { "type": "object", "properties": { @@ -97,7 +97,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF) readable via vault_read_file; !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image file is still cheap to read.\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF) readable via vault_read_file. When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image is still cheap to read.\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -125,17 +125,18 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nParameters:\n- folder: folder path filter (e.g. \"attachments\" or \"Projects/media\"), searched recursively; omit for the whole vault. This tool reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n- extensions: restrict to these extensions — case-insensitive, with or without the leading dot (\".png\" and \"png\" both work)\n- limit: maximum entries returned (default 50). extension_counts and total always reflect the full filtered set, not just the returned page.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Files of supported types are readable via vault_read_file.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: Reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Only supported types are readable via vault_read_file.", "inputSchema": { "type": "object", "properties": { "folder": { - "description": "Folder path to search recursively (e.g. \"attachments\"). Omit to list the whole vault.", + "description": "Folder path to search recursively (e.g. \"attachments\" or \"Projects/media\"). Omit to list the whole vault.", "type": "string", "minLength": 1 }, "extensions": { "description": "Only include these extensions, case-insensitive, leading dot optional (e.g. [\".png\", \"jpg\"]).", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -260,7 +261,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -279,13 +280,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -443,6 +444,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -502,7 +504,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -539,7 +541,7 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\", raw: true }) — the canvas's exact JSON source\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\nExample: vault_read_file({ path: \"papers/research.pdf\", raw: true }) — each page rendered as an image block\n\nWhat each type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. Set raw: true for the exact JSON source instead (geometry, ids, colors — full fidelity).\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved. Set raw: true for page images instead — each page rendered and returned as an image block, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n- Line paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhat each file type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n\nraw: true switches a canvas or PDF to its other form:\n- Canvas: the exact JSON source (geometry, ids, colors — full fidelity) instead of the outline.\n- PDF: each page rendered and returned as an image block instead of extracted text, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n\nLine paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\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 (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON, so no outline can be built; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- an image that cannot be decoded (corrupt, empty, or not an image despite its extension) fails with the decoder's message, e.g. \"Input buffer contains unsupported image format\"; replace or re-export the file\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", "inputSchema": { "type": "object", "properties": { @@ -583,7 +585,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", "inputSchema": { "type": "object", "properties": { @@ -601,7 +603,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -612,13 +614,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -678,17 +680,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -740,7 +742,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -792,14 +794,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -821,7 +823,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -832,7 +834,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -907,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "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 a9807748d..85675878b 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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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 — 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({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\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- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after 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\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": "Folders to exclude — replaces the defaults ([\"Daily Notes\",\"Templates\",\"About Me\"]), not merged", + "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", "type": "array", "items": { "type": "string", @@ -71,7 +71,7 @@ { "name": "vault_get_daily_note", "title": "Get Daily Note", - "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD. Defaults to today if no date is provided.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date is ISO YYYY-MM-DD (e.g. \"2026-05-13\"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format (e.g. \"2026-05-13\", not \"May 13\")\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", + "description": "Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to \"Daily Notes\" and YYYY-MM-DD.\n\nExample: vault_get_daily_note({ date: \"2026-05-13\" })\nExample: vault_get_daily_note({}) — returns today's daily note\n\nWhen to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes).\n\nParameters:\n- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet.\n\nErrors:\n- \"invalid date\" — use YYYY-MM-DD format\n- \"daily note format contains unsupported token(s): ...\" — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative\n\nReturns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). When exists is false, the note has not been created yet.", "inputSchema": { "type": "object", "properties": { @@ -96,7 +96,7 @@ { "name": "vault_get_memory", "title": "Get Memory", - "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when the file lists new entries at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. Designed for reconciliation consumers that know a boundary date and need deterministic chronological coverage without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" — section was provided without file; pass both or just file\n- \"on_or_after requires a file\" — on_or_after needs a file to scope the filter\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" — a dot-prefixed name would be a hidden file; memory files are always visible notes\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", + "description": "Read semantic memory from About Me/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet.\n\nWith file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. It gives complete coverage from a known date without re-parsing the section.\n\nExample: vault_get_memory({ file: \"Principles\", section: \"Decision heuristics (newest first)\" })\nExample: vault_get_memory({ file: \"Opinions\", section: \"Code patterns\", on_or_after: \"2026-09-01\" })\nExample: vault_get_memory({ file: \"Opinions\", on_or_after: \"2026-09-01\" }) — every section's entries since the date\n\nWhen to use: Reading user preferences, principles, opinions, or other persistent context stored in About Me/ files. Call vault_list_memory_files first to discover valid file and section names. Use on_or_after when you need entries from a known date forward (e.g. reconciliation boundaries).\nPrefer vault_read_note for reading non-memory notes.\n\nErrors:\n- \"section requires a file\" / \"on_or_after requires a file\" — section or on_or_after was passed without file; add file\n- \"memory file not found\" — file does not exist in About Me/; call vault_list_memory_files to discover valid names\n- \"memory file must not start with a dot\" / \"memory file must be a bare name without path separators\" — pass the file's bare name: no folder or slash, and no leading dot (that would be a hidden file; memory files are always visible notes)\n- \"section not found: …\" — no H2 heading matches; the error lists the file's available sections\n- \"date must be a real ISO calendar date\" — on_or_after must be a valid YYYY-MM-DD date\n\nReturns: Without on_or_after, raw markdown text. With on_or_after, JSON { entries, total, on_or_after } where each entry is { file, section, date, text } — text is the full raw entry markdown (bullet + continuation lines, wikilinks intact), same shape as vault_memory_recall entries. An empty match returns { entries: [], total: 0 }.", "inputSchema": { "type": "object", "properties": { @@ -131,7 +131,7 @@ { "name": "vault_get_outgoing_links", "title": "Get Outgoing Links", - "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"): exists+note = readable via vault_read_note; exists+file = non-markdown file (.canvas, image, PDF) readable via vault_read_file; !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image file is still cheap to read.\n\nErrors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", + "description": "Find all notes and files a given note or canvas links to. For notes: outgoing [[wikilinks]] and [markdown](links). For canvas files: file-node references (vault-relative paths embedded in the canvas). Links inside code blocks are ignored; self-links are included.\n\nExample: vault_get_outgoing_links({ path: \"Projects/vault-cortex.md\" })\nExample: vault_get_outgoing_links({ path: \"Diagrams/architecture.canvas\" })\n\nWhen to use: Navigating the graph forward, auditing broken links in one note or canvas, or checking dependencies before editing.\nFor incoming links (what links TO a note), use vault_get_backlinks.\n\nParameters:\n- path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds).\n\nReturns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind (\"note\"|\"file\"). When exists is true: kind \"note\" is a markdown note readable via vault_read_note; kind \"file\" is a non-markdown file (.canvas, image, PDF) readable via vault_read_file. When exists is false the link is broken (kind is always \"note\"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected \"create on click\" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links) — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image is still cheap to read.\n\nErrors:\n- \"path must end in …\" — add the .md or .canvas extension\n- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.", "inputSchema": { "type": "object", "properties": { @@ -159,17 +159,18 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nParameters:\n- folder: folder path filter (e.g. \"attachments\" or \"Projects/media\"), searched recursively; omit for the whole vault. This tool reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n- extensions: restrict to these extensions — case-insensitive, with or without the leading dot (\".png\" and \"png\" both work)\n- limit: maximum entries returned (default 50). extension_counts and total always reflect the full filtered set, not just the returned page.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Files of supported types are readable via vault_read_file.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — every non-markdown file in the vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: Reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing.\n\nErrors:\n- A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error.\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder starts at the filesystem root, escapes the vault (e.g. \"../elsewhere\") or names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Only supported types are readable via vault_read_file.", "inputSchema": { "type": "object", "properties": { "folder": { - "description": "Folder path to search recursively (e.g. \"attachments\"). Omit to list the whole vault.", + "description": "Folder path to search recursively (e.g. \"attachments\" or \"Projects/media\"). Omit to list the whole vault.", "type": "string", "minLength": 1 }, "extensions": { "description": "Only include these extensions, case-insensitive, leading dot optional (e.g. [\".png\", \"jpg\"]).", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -199,7 +200,7 @@ { "name": "vault_list_memory_files", "title": "List Memory Files", - "description": "Discovery tool — lists About Me/ memory files with their H1/H2 heading structure, per-section entry counts, and each file's leading callout (by convention a \"Scope of this file\" block describing what belongs in it). An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy. leading_callout is the file's top-of-file callout ({ type, title, body }), or null.", + "description": "Discovery tool — lists About Me/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line (\"- **YYYY-MM-DD**: text\"). Does NOT return actual entries.\n\nExample: vault_list_memory_files() returns [{ file: \"Principles\", title: \"Principles\", bytes: 2048, entry_policy: \"append-only\", leading_callout: null, headings: [{ level: 2, text: \"Decision heuristics (newest first)\", entry_count: 12 }] }, ...]\n\nWhen to use: Discovering what memory files and sections exist — and what each file is for — BEFORE calling vault_get_memory. Always call this first to get valid file and section names.\n\nBehavior: Lists every .md file directly inside About Me/, sorted by file name, whether or not it has frontmatter or a scope callout. Subfolders are not read, and hidden (dot-prefixed) files are skipped.\n\nErrors:\n- An empty or nonexistent memory folder returns an empty array, not an error.\n\nReturns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a \"Scope of this file\" block describing what belongs in the file), or null; entry_policy is \"append-only\" (the default: by convention, entries are never edited or deleted) or \"living\" (a current-state file whose expired entries may be pruned; declared via `entry-policy` frontmatter). The server does not enforce either policy.", "inputSchema": { "type": "object", "properties": {}, @@ -313,7 +314,7 @@ { "name": "vault_list_tags", "title": "List Tags", - "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nReturns: JSON array of { tag, count } sorted by count descending. tag omits the \"#\" prefix; count is unique notes with this tag.", + "description": "List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. \"project/vault-cortex\") appears as one full entry, not split into segments. Count is unique notes, not occurrences.\n\nExample: vault_list_tags() returns [{ tag: \"session-log\", count: 42 }, { tag: \"project/vault-cortex\", count: 8 }, ...]\n\nWhen to use: Discovering what tags exist before searching by tag. Good first step for vault orientation.\nPrefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching (\"project\" matches \"project/*\").\n\nErrors:\n- A vault with no tagged notes returns an empty array, not an error.\n\nReturns: JSON array of { tag, count }; tag omits the \"#\" prefix.", "inputSchema": { "type": "object", "properties": {}, @@ -332,13 +333,13 @@ { "name": "vault_list_tasks", "title": "List Tasks", - "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement)\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content.\n\nParameters:\n- status: a single value or an array of values, OR-combined (default \"not_done\"). Values: \"not_done\" (todo + in_progress, excludes done AND cancelled), \"todo\", \"in_progress\", \"done\", \"cancelled\", \"all\". Virtual values expand in arrays: [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date.\n- priority: array of \"highest\" | \"high\" | \"medium\" | \"low\" | \"lowest\" | \"none\", OR-combined (\"none\" = tasks with no priority signifier).\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in \".md\".\n- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items.\n- sort_by: \"due\" (default) | \"scheduled\" | \"start\" | \"created\" | \"done\" | \"priority\" | \"note_mtime\" | \"position\". \"position\" sorts by file path then line number — the natural order for Kanban boards.\n- limit: max results (default 50). The total field always reports the full match count.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", + "description": "List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded.\n\nExample: vault_list_tasks({ due: { before: \"2026-07-04\" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the \"what's overdue?\" call\nExample: vault_list_tasks({ path: \"Code Projects/vault-cortex/TASKS.md\", heading: [\"Active\", \"Up Next\", \"Waiting On\"], sort_by: \"position\" }) — actionable Kanban lanes in board order\nExample: vault_list_tasks({ folder: \"Code Projects/vault-cortex\" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories)\nExample: vault_list_tasks({ status: \"done\", done: { after: \"2026-06-26\" } }) — what got completed this week\nExample: vault_list_tasks({ top_level_only: true, path: \"TASKS.md\" }) — board cards only, excluding checklist sub-items\n\nWhen to use: Reading task status and order on one board (path + sort_by: \"position\"; add status: \"all\" to include done and cancelled cards). Also any vault-wide task triage question — \"what's overdue?\", \"what's open per project?\", \"what did I finish this week?\" — in one call instead of per-board reads.\nPrefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. Prefer vault_search for full-text queries over note content.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state.\n\nParameters:\n- status: virtual values expand in arrays — [\"not_done\", \"done\"] matches todo + in_progress + done.\n- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date.\n- folder: a whole folder, subfolders included (\"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n\nErrors:\n- A malformed or calendar-invalid date filter throws with remediation text (\"Use YYYY-MM-DD\")\n- path without the \".md\" extension is rejected\n- No matches returns { total: 0, tasks: [] }, not an error\n\nReturns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status \"done\" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.", "inputSchema": { "type": "object", "properties": { "status": { "default": "not_done", - "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: \"not_done\" adds todo + in_progress, \"all\" includes every status.", + "description": "Status filter, OR-combined (default \"not_done\" = todo + in_progress, excluding done and cancelled). \"all\" includes every status.", "anyOf": [ { "type": "string", @@ -496,6 +497,7 @@ }, "priority": { "description": "Priority levels, OR-combined; \"none\" selects tasks with no priority signifier", + "minItems": 1, "type": "array", "items": { "type": "string", @@ -555,7 +557,7 @@ }, "sort_by": { "default": "due", - "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", + "description": "Sort key (default \"due\"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. \"position\" sorts by file path then line number — the natural order for Kanban boards.", "type": "string", "enum": [ "due", @@ -592,7 +594,7 @@ { "name": "vault_memory_recall", "title": "Memory Recall", - "description": "Recall memory entries about a topic — entry-granular hybrid (keyword + semantic) retrieval across ALL About Me/ files and ALL time. Returns every relevant dated entry sorted oldest-first, so the full evolution of a preference, opinion, or fact is visible — semantic matching finds early entries even when their phrasing differs from the query. Tuned for recall over precision: expect some marginal entries and judge relevance yourself when synthesizing an answer. Content-word queries (\"testing philosophy\", \"sustainable pacing\") rank best; a meta-framed query (\"opinions on testing\") whose relevance cut would come back empty degrades to relaxed any-term keyword matching instead of returning nothing.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"opinions on testing\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. search_mode is \"hybrid\" when vector matching contributed, \"fts\" when the entries came from keyword matching alone — including the any-term fallback that rescues a would-be-empty result; reranked is true when the cross-encoder relevance cut was applied.", + "description": "Recall memory entries about a topic — entry-granular hybrid (keyword + semantic) retrieval across ALL About Me/ files and ALL time. Returns every relevant dated entry sorted oldest-first, so the full evolution of a preference, opinion, or fact is visible — semantic matching finds early entries even when their phrasing differs from the query. Tuned for recall over precision: expect some marginal entries and judge relevance yourself when synthesizing an answer. Content-word queries (\"testing philosophy\", \"sustainable pacing\") rank best. A meta-framed query (\"opinions on testing\") that scores below the relevance threshold degrades to relaxed any-term keyword matching instead of returning nothing.\n\nExample: vault_memory_recall({ query: \"working hours and pacing\" })\nExample: vault_memory_recall({ query: \"testing philosophy\", file: \"Opinions\" })\n\nWhen to use: Answering \"what does my memory say about X?\" or \"how has my view on Y evolved?\" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer.\n\nErrors:\n- No matching entries returns { entries: [], total: 0 }, not an error\n- An unknown file returns empty results — call vault_list_memory_files to discover valid names\n\nReturns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included); file and section feed directly into vault_get_memory. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. file is applied before limit, so limit keeps the most relevant matches from that file. search_mode is \"hybrid\" when vector matching contributed, \"fts\" when the entries came from keyword matching alone — including the any-term fallback that rescues a would-be-empty result; reranked is true when the cross-encoder relevance cut was applied.", "inputSchema": { "type": "object", "properties": { @@ -608,7 +610,7 @@ }, "limit": { "default": 50, - "description": "Cap on returned entries (default 50). When more match, the least-relevant are dropped and truncated=true — never a date range.", + "description": "Cap on returned entries (default 50).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -632,7 +634,7 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\", raw: true }) — the canvas's exact JSON source\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\nExample: vault_read_file({ path: \"papers/research.pdf\", raw: true }) — each page rendered as an image block\n\nWhat each type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. Set raw: true for the exact JSON source instead (geometry, ids, colors — full fidelity).\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved. Set raw: true for page images instead — each page rendered and returned as an image block, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n- Line paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhat each file type returns:\n- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, 49152 bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed.\n- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved.\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.\n\nraw: true switches a canvas or PDF to its other form:\n- Canvas: the exact JSON source (geometry, ids, colors — full fidelity) instead of the outline.\n- PDF: each page rendered and returned as an image block instead of extracted text, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first 5 pages are rendered; the text read (without raw) covers every page.\n\nLine paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact.\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nErrors:\n- \"not a file\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — nothing exists at that path; discover valid paths via vault_list_files\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 (hidden files are not readable, matching Obsidian)\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB)\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON, so no outline can be built; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF page rendering failed\" — raw: true was set but no pages could be rendered; the PDF may be corrupt\n- \"image cannot be fitted\" — the image could not be compressed under the image output budget\n- an image that cannot be decoded (corrupt, empty, or not an image despite its extension) fails with the decoder's message, e.g. \"Input buffer contains unsupported image format\"; replace or re-export the file\n- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size\n\nReturns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.", "inputSchema": { "type": "object", "properties": { @@ -676,7 +678,7 @@ { "name": "vault_read_note", "title": "Read Note", - "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. Prefer vault_get_memory for About Me/ files (returns content without properties). To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like \".obsidian/\"; use a vault-relative path outside hidden folders\n\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", + "description": "Read a markdown note by its vault-relative path. By default returns the full raw content including properties; optional modes return just the properties, just the heading outline, or just one section — so large notes don't blow the token budget.\n\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\" })\nExample: vault_read_note({ path: \"Projects/vault-cortex.md\", properties_only: true })\nExample: vault_read_note({ path: \"TASKS.md\", outline: true })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Active\" })\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", heading_level: 2 }) // disambiguate when several \"Done\" headings exist\nExample: vault_read_note({ path: \"TASKS.md\", heading: \"Done\", start_line: 1, limit: 20 }) // first 20 lines of an oversized section\n\nWhen to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: \"...\" to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total.\nPrefer vault_search when you don't know the path. For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown. Prefer vault_get_memory for About Me/ files (returns content without properties). To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links.\n\nSection boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical.\n\nErrors:\n- \"note not found\" — no note exists at this path; verify it with vault_list_notes\n- \"heading not found\" — no heading matches the text; error lists available headings\n- \"ambiguous heading\" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level\n- \"outline, heading, and properties_only are mutually exclusive\" — only one mode per call\n- \"heading_level requires a heading\" — heading_level only disambiguates a heading; pass heading with it\n- \"heading cannot be empty\" — heading is whitespace only; pass the heading's text\n- \"line paging is not available in outline mode\" / \"... properties_only mode\" — start_line/limit only work on text renditions (full read or heading section)\n- \"start line past the end\" — start_line exceeds the rendition's line count; error states the total\n- 'path must end in \".md\"' — the path names a non-markdown file; read files (images, .canvas, data files) with vault_read_file instead\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\nReturns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block (\"path — lines 1–20 of 250 (continue with start_line: 21)\").\n\nOutline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings (\"##\" with no text) appear with text: \"\" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note.", "inputSchema": { "type": "object", "properties": { @@ -694,7 +696,7 @@ "type": "boolean" }, "heading": { - "description": "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", + "description": "Return only this section. Case-sensitive exact match.", "type": "string", "minLength": 1 }, @@ -705,13 +707,13 @@ "maximum": 6 }, "start_line": { - "description": "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "description": "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "limit": { - "description": "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", + "description": "Maximum lines returned (default: all remaining).", "type": "integer", "minimum": 1, "maximum": 9007199254740991 @@ -771,17 +773,17 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- tags: require all listed tags (AND)\n- type: exact match on frontmatter type (e.g. \"person\", \"session-log\")\n- related: require all listed related links (AND)\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }); values compare by exact type (pass a number as a number, not \"4\"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value\n- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match\n- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", + "description": "Hybrid search across all vault notes, ranked by combined keyword and semantic relevance using Reciprocal Rank Fusion (RRF) — combining FTS5 keyword matching with vector similarity. Results are refined by a cross-encoder reranker using position-aware score blending when available. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Falls back to keyword-only (FTS5 BM25) transparently while embeddings are being built. Combine a text query with structured filters to narrow results by metadata — the \"narrow by metadata, search by text\" pattern. Unquoted terms use implicit AND with porter stemming; wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases automatically.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included — \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\"; ignores ASCII letter case\n- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: \"active\" }). Values compare by exact type — pass a number as a number, not \"4\". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"how the server watches for file changes\" }) — semantic: finds notes about chokidar and file watchers even without those exact terms\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata. Semantic matching bridges vocabulary gaps — try natural-language queries, not just keywords.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nErrors:\n- No matches returns { results: [], total: 0 }, not an error\n- Malformed query syntax is sanitized automatically — the tool never throws a query syntax error\n- A malformed or calendar-invalid created/modified date filter throws with remediation text (\"Use YYYY-MM-DD\")\n\nReturns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" or \"fts\"), and reranked (boolean — true when cross-encoder reranking refined the ordering). search_mode indicates which ranking was used — \"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching was available. score reflects combined relevance (higher = more relevant). kind is \"note\" for markdown notes or \"file\" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. \".canvas\", \".pdf\", \".txt\"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "minLength": 1, - "description": "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases" + "description": "Search query text" }, "filters": { - "description": "Optional structured filters — all conditions AND-combine with each other and with the text query", + "description": "Optional structured filters", "type": "object", "properties": { "folder": { @@ -833,7 +835,7 @@ } }, "created": { - "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property — notes without a parseable value never match", + "description": "Created date bounds (YYYY-MM-DD) on the frontmatter \"created\" property", "type": "object", "properties": { "before": { @@ -885,14 +887,14 @@ }, "snippet_tokens": { "default": 30, - "description": "Snippet length in tokens (default 30)", + "description": "Snippet length in words (default 30)", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "include_leading_callout": { "default": false, - "description": "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "description": "If true, include each result's leading callout. Off by default to keep results lean.", "type": "boolean" } }, @@ -914,7 +916,7 @@ { "name": "vault_search_by_folder", "title": "Search by Folder", - "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- recursive (default true) includes all nested subfolders; set false to list only the folder's top level.\n- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.", + "description": "Browse notes in a folder with full metadata (tags, type, related, created, modified) — unlike vault_list_notes, which returns paths only.\n\nExample: vault_search_by_folder({ folder: \"Projects\" }) or vault_search_by_folder({ folder: \"About Me\", recursive: false })\n\nWhen to use: Exploring a folder's contents with full context for vault orientation.\nPrefer vault_list_notes when you only need paths. Prefer vault_search when you have a text query. Use vault_get_backlinks or vault_get_outgoing_links to explore how notes in a folder connect to the rest of the vault.\n\nParameters:\n- folder names a whole folder, not a text prefix: \"Projects\" matches notes under \"Projects/\" but not \"ProjectsOld/\". Matching ignores ASCII letter case, and a trailing slash is ignored.\n- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nBehavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear.\n\nErrors:\n- An empty or nonexistent folder returns an empty array, not an error.\n\nReturns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).", "inputSchema": { "type": "object", "properties": { @@ -925,7 +927,7 @@ }, "recursive": { "default": true, - "description": "Include subfolders (default: true)", + "description": "Include subfolders (default: true); false lists only the folder's top level", "type": "boolean" }, "limit": { @@ -1000,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\" }) returns notes tagged project or project/*.\n\nWhen to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a 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 is the bare tag name without a leading \"#\" (\"project\", not \"#project\"). Hierarchical tags use \"/\" separators (\"project/vault-cortex\").\n- tag + exact interact: with exact=false (default), \"project\" matches \"project\", \"project/vault-cortex\", \"project/blog\" — the match is prefix-based on the \"/\" separator, so \"project\" does NOT match \"my-project\" or \"projects\". Set exact=true to match 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 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.", "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 242573045..dd888dec3 100644 --- a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts @@ -71,6 +71,19 @@ beforeEach(() => { calls = mockServer.registerTool.mock.calls as RegisterToolCall[] }) +/** The registerTool calls a server makes under the config this env produces. */ +const registerWithConfig = (env: Record): RegisterToolCall[] => { + const server = { registerTool: vi.fn() } + registerTools({ + server: server as unknown as McpServer, + vaultPath: "/test-vault", + search: {} as SearchIndex, + logger, + config: loadConfig(env), + }) + return server.registerTool.mock.calls as RegisterToolCall[] +} + const findCall = (name: string): RegisterToolCall | undefined => { return calls.find(([toolName]) => toolName === name) } @@ -193,11 +206,11 @@ describe("registerTools", () => { expect(config.description).toContain("Cross-section move") }) - it("vault_read_note description documents the outline response", () => { + it("vault_read_note documents the outline response on the outline parameter", () => { // The only guard against this drifting from the actual response shape. const [, config] = requireCall(TOOL_NAMES.VAULT_READ_NOTE) - expect(config.description).toContain( - "Outline shape: { bytes, modified, leading_callout?, leading_content?, headings }", + expect(config.inputSchema?.outline?.description).toBe( + "If true, returns { bytes, modified, leading_callout?, leading_content?, headings } as JSON instead of body content — a cheap structure fetch for large notes. headings: [{ level, text, bytes }]; leading_callout: { type, title, body } when the note has a top-of-file callout; leading_content: the rest of the body text above the first heading (callout lines excluded) when the note has any.", ) }) @@ -428,18 +441,7 @@ describe("annotations", () => { describe("config interpolation in descriptions", () => { const CUSTOM_MEMORY_DIR = "Profile" - const customConfig = loadConfig({ MEMORY_DIR: CUSTOM_MEMORY_DIR }) - const customCalls = (() => { - const server = { registerTool: vi.fn() } - registerTools({ - server: server as unknown as McpServer, - vaultPath: "/test-vault", - search: {} as SearchIndex, - logger, - config: customConfig, - }) - return server.registerTool.mock.calls as RegisterToolCall[] - })() + const customCalls = registerWithConfig({ MEMORY_DIR: CUSTOM_MEMORY_DIR }) /** Like requireCall, but over the custom-config registration — throws * instead of returning undefined so call sites need no non-null assertion. */ @@ -783,6 +785,26 @@ describe("optional selector params reject an empty string", () => { }) }) +describe("optional filter lists reject an empty array", () => { + // - A "match any of these" list (the four below) selects nothing when empty, + // so an empty one is a caller mistake. Without min(1) the call succeeds + // and the empty or unfiltered result reads as a real answer. + // - A "require all of these" list (vault_search's tags and related) stays + // valid when empty, because requiring no tags is no constraint. + it.each([ + { tool: TOOL_NAMES.VAULT_LIST_FILES, field: "extensions", validValue: [".png"] }, + { tool: TOOL_NAMES.VAULT_LIST_TASKS, field: "priority", validValue: ["high"] }, + { tool: TOOL_NAMES.VAULT_LIST_TASKS, field: "status", validValue: ["todo"] }, + { tool: TOOL_NAMES.VAULT_LIST_TASKS, field: "heading", validValue: ["Active"] }, + ])("$tool $field rejects [] and accepts a list or no value", ({ tool, field, validValue }) => { + const [, config] = requireCall(tool) + const fieldSchema = config.inputSchema?.[field] + expect(fieldSchema?.safeParse([]).success).toBe(false) + expect(fieldSchema?.safeParse(validValue).success).toBe(true) + expect(fieldSchema?.safeParse(undefined).success).toBe(true) + }) +}) + describe("vault_update_memory handler", () => { const mockExtra = { requestId: "test-1", sessionId: "session-1" } @@ -991,19 +1013,6 @@ describe("vault_read_note outline mode", () => { }) describe("vault_search description reflects EMBEDDING_ENABLED", () => { - const registerWithConfig = (env: Record): RegisterToolCall[] => { - const server = { registerTool: vi.fn() } - registerTools({ - server: server as unknown as McpServer, - vaultPath: "/test-vault", - search: {} as SearchIndex, - logger, - config: loadConfig(env), - }) - const registeredCalls = server.registerTool.mock.calls as RegisterToolCall[] - return registeredCalls - } - const findSearchDescription = (registeredCalls: RegisterToolCall[]): string => { const searchCall = registeredCalls.find(([name]) => name === TOOL_NAMES.VAULT_SEARCH) @@ -1034,19 +1043,48 @@ describe("vault_search description reflects EMBEDDING_ENABLED", () => { }) }) -describe("vault_memory_recall description reflects EMBEDDING_ENABLED", () => { - const registerWithConfig = (env: Record): RegisterToolCall[] => { - const server = { registerTool: vi.fn() } - registerTools({ - server: server as unknown as McpServer, - vaultPath: "/test-vault", - search: {} as SearchIndex, - logger, - config: loadConfig(env), +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({ + registeredCalls: registerWithConfig(env), + toolName: TOOL_NAMES.VAULT_DELETE_NOTE, + startMarker: "Errors:", + endMarker: "\n\nReturns:", }) - return server.registerTool.mock.calls as RegisterToolCall[] + const [, ...errorEntries] = errorsSection.split("\n") + return errorEntries.map((entry) => entry.slice(0, entry.indexOf(" — "))) } + 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"', + ]) + }) + + 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"', + ]) + }) +}) + +describe("vault_memory_recall description reflects EMBEDDING_ENABLED", () => { const findRecallDescription = (registeredCalls: RegisterToolCall[]): string => { const recallCall = registeredCalls.find(([name]) => name === TOOL_NAMES.VAULT_MEMORY_RECALL) @@ -1086,15 +1124,7 @@ describe("MEMORY_ENABLED=false", () => { const NON_MEMORY_TOOL_COUNT = ALL_TOOL_NAMES.length - MEMORY_TOOLS.length const registerWithDisabledMemory = (): RegisterToolCall[] => { - const server = { registerTool: vi.fn() } - registerTools({ - server: server as unknown as McpServer, - vaultPath: "/test-vault", - search: {} as SearchIndex, - logger, - config: loadConfig({ MEMORY_ENABLED: "false" }), - }) - return server.registerTool.mock.calls as RegisterToolCall[] + return registerWithConfig({ MEMORY_ENABLED: "false" }) } it("does not register memory tools", () => { @@ -1133,15 +1163,7 @@ describe("FILE_TOOLS_ENABLED=false", () => { const EXPECTED_NON_FILE_TOOLS = ALL_TOOL_NAMES.filter((toolName) => !FILE_TOOL_SET.has(toolName)) const registerWithDisabledFileTools = (): RegisterToolCall[] => { - const server = { registerTool: vi.fn() } - registerTools({ - server: server as unknown as McpServer, - vaultPath: "/test-vault", - search: {} as SearchIndex, - logger, - config: loadConfig({ FILE_TOOLS_ENABLED: "false" }), - }) - return server.registerTool.mock.calls as RegisterToolCall[] + return registerWithConfig({ FILE_TOOLS_ENABLED: "false" }) } it("does not register file tools", () => { @@ -1176,15 +1198,7 @@ describe("READONLY_MODE=true", () => { ) const registerReadOnly = (extraEnv: Record = {}): RegisterToolCall[] => { - const server = { registerTool: vi.fn() } - registerTools({ - server: server as unknown as McpServer, - vaultPath: "/test-vault", - search: {} as SearchIndex, - logger, - config: loadConfig({ READONLY_MODE: "true", ...extraEnv }), - }) - return server.registerTool.mock.calls as RegisterToolCall[] + return registerWithConfig({ READONLY_MODE: "true", ...extraEnv }) } it("does not register mutating tools", () => { @@ -1763,6 +1777,15 @@ describe("file tool handlers", () => { }) }) + it("returns the source of a canvas that is not valid JSON when raw is true", async () => { + const { vault, readAsset } = await setupAssetHarness() + await writeFile(join(vault, "Broken.canvas"), "{ not json", "utf8") + const result = await readAsset({ path: "Broken.canvas", raw: true }) + expect(result).toEqual({ + content: [{ type: "text", text: "{ not json" }], + }) + }) + it("rejects raw for an image", async () => { const { vault, readAsset } = await setupAssetHarness() const png = await sharp({ @@ -1926,6 +1949,22 @@ describe("file tool handlers", () => { }) }) + it("lists a file without an extension under the (none) marker", async () => { + const { vault, listAssets } = await setupAssetHarness() + await writeFile(join(vault, "license"), "12345", "utf8") + await writeFile(join(vault, "photo.png"), "12", "utf8") + const result = await listAssets({}) + expect(JSON.parse(requireTextContent(result))).toEqual({ + files: [ + { path: "license", extension: "(none)", bytes: 5 }, + { path: "photo.png", extension: ".png", bytes: 2 }, + ], + extension_counts: { "(none)": 1, ".png": 1 }, + total: 2, + truncated: false, + }) + }) + it.each(["PNG", ".PNG"])( "filters by extension case-insensitively for %s", async (extensionSpelling) => { @@ -1958,18 +1997,6 @@ describe("file tool handlers", () => { }) describe("DISABLED_TOOLS", () => { - const registerWithConfig = (env: Record): RegisterToolCall[] => { - const server = { registerTool: vi.fn() } - registerTools({ - server: server as unknown as McpServer, - vaultPath: "/test-vault", - search: {} as SearchIndex, - logger, - config: loadConfig(env), - }) - return server.registerTool.mock.calls as RegisterToolCall[] - } - it("hides exactly the named tools and keeps every other tool", () => { const registeredCalls = registerWithConfig({ DISABLED_TOOLS: "vault_write_note,vault_find_orphans", @@ -2035,6 +2062,43 @@ describe("DISABLED_TOOLS", () => { expect(enabledReadNoteCall?.[1].description).toContain(TOOL_NAMES.VAULT_PATCH_NOTE) }) + it("vault_read_note's board guidance names vault_list_tasks only while that tool is served", () => { + const readNoteRoutingLine = (disabledTools: string): string => { + return extractDescriptionSection({ + registeredCalls: registerWithConfig({ DISABLED_TOOLS: disabledTools }), + toolName: TOOL_NAMES.VAULT_READ_NOTE, + startMarker: "Prefer vault_search", + endMarker: "\n\nSection boundaries:", + }) + } + const ROUTING_LINE_START = "Prefer vault_search when you don't know the path." + const ROUTING_LINE_END = + " Prefer vault_get_memory for About Me/ files (returns content without properties). To edit a section you've read, use vault_patch_note. To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links." + + expect(readNoteRoutingLine("")).toBe( + `${ROUTING_LINE_START} For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown.${ROUTING_LINE_END}`, + ) + expect(readNoteRoutingLine("vault_list_tasks")).toBe(`${ROUTING_LINE_START}${ROUTING_LINE_END}`) + }) + + it("vault_list_tasks' lane guidance names vault_read_note only while that tool is served", () => { + const listTasksRoutingLines = (disabledTools: string): string => { + return extractDescriptionSection({ + registeredCalls: registerWithConfig({ DISABLED_TOOLS: disabledTools }), + toolName: TOOL_NAMES.VAULT_LIST_TASKS, + startMarker: "in one call instead of per-board reads.", + endMarker: "\n\nBehavior:", + }) + } + const TRIAGE_LINE_END = "in one call instead of per-board reads.\n" + const SEARCH_ROUTING = "Prefer vault_search for full-text queries over note content." + + expect(listTasksRoutingLines("")).toBe( + `${TRIAGE_LINE_END}Prefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. ${SEARCH_ROUTING}`, + ) + expect(listTasksRoutingLines("vault_read_note")).toBe(`${TRIAGE_LINE_END}${SEARCH_ROUTING}`) + }) + 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/__tests__/tool-surface-snapshot.test.ts b/src/vault-mcp/mcp-core/__tests__/tool-surface-snapshot.test.ts index e06aacd58..c5e2b7134 100644 --- a/src/vault-mcp/mcp-core/__tests__/tool-surface-snapshot.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/tool-surface-snapshot.test.ts @@ -33,10 +33,10 @@ describe("tool surface baseline", () => { * tool count, so a new tool adds one allowance. The cap has two blind spots: * - It checks a combo's total, so one tool can grow while another shrinks; * `npm run report:tool-surface-size` shows the per-tool sizes. - * - The allowance sits just above the default combo's average when it was - * set (4,054 chars per tool), so only small growth passes. + * - The allowance is the default combo's average when it was set (3,745 + * chars per tool) plus 2%, so only small growth passes. * Raising the allowance is a deliberate change with its reason in the PR. */ - const CHARS_PER_TOOL_ALLOWANCE = 4055 + const CHARS_PER_TOOL_ALLOWANCE = 3821 it.each(SIZE_CAPPED_COMBO_NAMES)( "combo %s stays within the average per-tool size allowance", diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index 1c6abb989..050883384 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -86,33 +86,38 @@ export const registerAssetTools = ({ Example: vault_read_file({ path: "attachments/diagram.png" }) — the image itself, shrunk when too large Example: vault_read_file({ path: "Boards/Roadmap.canvas" }) — a readable outline of the canvas -Example: vault_read_file({ path: "Boards/Roadmap.canvas", raw: true }) — the canvas's exact JSON source Example: vault_read_file({ path: "exports/data.json" }) — the file content as text Example: vault_read_file({ path: "exports/big.csv", limit: 500 }) — the first 500 lines, preceded by a metadata line stating the window and total line count Example: vault_read_file({ path: "papers/research.pdf" }) — structured text with title, headings, and links -Example: vault_read_file({ path: "papers/research.pdf", raw: true }) — each page rendered as an image block -What each type returns: +What each file type returns: - Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds the image output budget (MAX_IMAGE_OUTPUT_BYTES, ${config.maxImageOutputBytes} bytes) or 1568 pixels on its longer side, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed. -- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. Set raw: true for the exact JSON source instead (geometry, ids, colors — full fidelity). -- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved. Set raw: true for page images instead — each page rendered and returned as an image block, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first ${config.maxPdfRenderPages} pages are rendered; the text read (without raw) covers every page. +- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. +- PDFs (.pdf): structured text with document metadata — title, page count, heading hierarchy (from font sizes relative to the body text), code blocks and inline code (from monospace fonts), page separators, and a deduplicated links footer. Richer than flat text extraction: headings, code, and hyperlinks that flat extraction loses are preserved. - Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source. -- Line paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue ("data.csv — lines 51–100 of 400 (continue with start_line: 101)"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact. + +raw: true switches a canvas or PDF to its other form: +- Canvas: the exact JSON source (geometry, ids, colors — full fidelity) instead of the outline. +- PDF: each page rendered and returned as an image block instead of extracted text, showing layout, diagrams, tables, and formatting that text extraction cannot preserve. Image-only and scanned PDFs work in raw mode. Only the first ${config.maxPdfRenderPages} pages are rendered; the text read (without raw) covers every page. + +Line paging: start_line and limit page any text result — text formats, canvas outlines and raw JSON, PDF-extracted text — as a 1-based line window, preceded by a metadata line stating the window, the total line count, and where to continue ("data.csv — lines 51–100 of 400 (continue with start_line: 101)"). The text output cap (100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. Paged windows come back with \\n line endings and no trailing newline; a read without paging inputs stays byte-exact. When to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note — this tool rejects them. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total. Errors: - "not a file" — the path ends in .md; read notes with vault_read_note - "file not found" — nothing exists at that path; discover valid paths via vault_list_files -- "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like ".obsidian/"; use a vault-relative path outside hidden folders (hidden files are not readable, matching Obsidian) +- "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it (hidden files are not readable, matching Obsidian) - "file too large" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB) -- "text output too large" — a text file or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows +- "text output too large" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows - "start line past the end" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line - "line range is not available" (start_line or limit on an image, or on a PDF read with raw: true) / "raw source is not available for images" (raw on an image) — drop that input; paging applies only to text results, and an image always comes back as its image block - "not valid UTF-8" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content +- "invalid .canvas JSON" — the canvas file is empty or not valid JSON, so no outline can be built; set raw: true to read its source as text - "PDF has no extractable text" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead - "PDF page rendering failed" — raw: true was set but no pages could be rendered; the PDF may be corrupt - "image cannot be fitted" — the image could not be compressed under the image output budget +- an image that cannot be decoded (corrupt, empty, or not an image despite its extension) fails with the decoder's message, e.g. "Input buffer contains unsupported image format"; replace or re-export the file - unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size Returns: for images, an image content block plus a one-line metadata text block; for PDFs with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page); for every other supported type, a single text content block — preceded by a window-metadata text block when start_line or limit was given.`, @@ -209,26 +214,24 @@ Example: vault_list_files({ extensions: [".png", ".jpg"], limit: 20 }) When to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links. -Parameters: -- folder: folder path filter (e.g. "attachments" or "Projects/media"), searched recursively; omit for the whole vault. This tool reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing. -- extensions: restrict to these extensions — case-insensitive, with or without the leading dot (".png" and "png" both work) -- limit: maximum entries returned (default 50). extension_counts and total always reflect the full filtered set, not just the returned page. +Behavior: Reads the filesystem rather than the search index, so use the folder's exact letter case; on a case-sensitive filesystem a different case finds nothing. Errors: - A visible folder containing no files — or one that doesn't exist — returns an empty listing, not an error. - "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — the folder starts at the filesystem root, escapes the vault (e.g. "../elsewhere") or names the vault root itself (e.g. "."), or is hidden like ".obsidian" (hidden folders are not listable, matching Obsidian); use a vault-relative folder outside hidden folders, and omit folder to list the whole vault. -Returns: JSON with files (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Files of supported types are readable via vault_read_file.`, +Returns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is "(none)" for a file without one), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_file returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers; one over 100 KiB must be read in windows with vault_read_file's start_line and limit. Only supported types are readable via vault_read_file.`, inputSchema: { folder: z .string() .min(1) .optional() .describe( - 'Folder path to search recursively (e.g. "attachments"). Omit to list the whole vault.', + 'Folder path to search recursively (e.g. "attachments" or "Projects/media"). Omit to list the whole vault.', ), extensions: z .array(z.string().min(1)) + .min(1) .optional() .describe( 'Only include these extensions, case-insensitive, leading dot optional (e.g. [".png", "jpg"]).', diff --git a/src/vault-mcp/mcp-core/tools/daily-note-tools.ts b/src/vault-mcp/mcp-core/tools/daily-note-tools.ts index 2eba62b8b..dc2fd23fb 100644 --- a/src/vault-mcp/mcp-core/tools/daily-note-tools.ts +++ b/src/vault-mcp/mcp-core/tools/daily-note-tools.ts @@ -18,7 +18,7 @@ export const registerDailyNoteTools = ({ TOOL_NAMES.VAULT_GET_DAILY_NOTE, { title: "Get Daily Note", - description: `Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to "Daily Notes" and YYYY-MM-DD. Defaults to today if no date is provided. + description: `Read a daily note by date, using the vault's configured Daily Notes folder and filename date format. Each setting comes from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env vars, falling back to the vault's .obsidian/daily-notes.json, then to "Daily Notes" and YYYY-MM-DD. Example: vault_get_daily_note({ date: "2026-05-13" }) Example: vault_get_daily_note({}) — returns today's daily note @@ -26,11 +26,11 @@ Example: vault_get_daily_note({}) — returns today's daily note When to use: When you need today's or a specific date's daily note. Handles path resolution automatically using the vault's Obsidian config — you don't need to know the folder name or filename format. ${whenToolEnabledText("vault_patch_note", "To append content to a daily note section, use the returned path with vault_patch_note. ")}Use vault_recent_notes to review recent vault activity around a date (not date-filtered — returns globally recent notes). Parameters: -- date is ISO YYYY-MM-DD (e.g. "2026-05-13"). Defaults to today in the server's local timezone. Past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. The path is derived from the server's daily-notes settings, so callers never need to construct daily note paths manually. +- date: past and future dates are both valid — the tool resolves the configured path for any date and reports exists: false if the note hasn't been created yet. Errors: -- "invalid date" — use YYYY-MM-DD format (e.g. "2026-05-13", not "May 13") -- "daily note format contains unsupported token(s): ..." — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative +- "invalid date" — use YYYY-MM-DD format +- "daily note format contains unsupported token(s): ..." — the configured format uses tokens the server cannot reproduce (ordinals like Do/Mo, dd, d, e, k/kk, w, Q, Z/ZZ, or the L-family localized formats); change the format in Obsidian or set DAILY_NOTES_FORMAT to a supported alternative Returns: JSON with path (string — resolved vault-relative path), content (string|null — full note body, or null when the note doesn't exist), and exists (boolean). ${isToolEnabled("vault_write_note") ? "When exists is false, create the note with vault_write_note using the returned path." : "When exists is false, the note has not been created yet."}`, inputSchema: { diff --git a/src/vault-mcp/mcp-core/tools/memory-tools.ts b/src/vault-mcp/mcp-core/tools/memory-tools.ts index 429ab1072..e1aee85cb 100644 --- a/src/vault-mcp/mcp-core/tools/memory-tools.ts +++ b/src/vault-mcp/mcp-core/tools/memory-tools.ts @@ -49,7 +49,7 @@ export const registerMemoryTools = ({ title: "Get Memory", description: `Read semantic memory from ${config.memoryDir}/ files. These are structured memory files containing dated bullet entries organized under H2 headings. With file: single file content. With file+section: just that H2 section's entries. No args: all files concatenated (frontmatter stripped) — can be large. Returns empty string when no memory files exist yet. -With file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when the file lists new entries at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. Designed for reconciliation consumers that know a boundary date and need deterministic chronological coverage without re-parsing the section. +With file+on_or_after: returns structured JSON entries dated on or after the boundary date (inclusive), in document order (newest first when new entries go at the top, the default). Add section to scope to one H2 section; omit it to read every H2 section's entries in the file, each keeping its section attribution. It gives complete coverage from a known date without re-parsing the section. Example: vault_get_memory({ file: "Principles", section: "Decision heuristics (newest first)" }) Example: vault_get_memory({ file: "Opinions", section: "Code patterns", on_or_after: "2026-09-01" }) @@ -59,10 +59,9 @@ When to use: Reading user preferences, principles, opinions, or other persistent Prefer vault_read_note for reading non-memory notes. Errors: -- "section requires a file" — section was provided without file; pass both or just file -- "on_or_after requires a file" — on_or_after needs a file to scope the filter +- "section requires a file" / "on_or_after requires a file" — section or on_or_after was passed without file; add file - "memory file not found" — file does not exist in ${config.memoryDir}/; call vault_list_memory_files to discover valid names -- "memory file must not start with a dot" — a dot-prefixed name would be a hidden file; memory files are always visible notes +- "memory file must not start with a dot" / "memory file must be a bare name without path separators" — pass the file's bare name: no folder or slash, and no leading dot (that would be a hidden file; memory files are always visible notes) - "section not found: …" — no H2 heading matches; the error lists the file's available sections - "date must be a real ISO calendar date" — on_or_after must be a valid YYYY-MM-DD date @@ -163,7 +162,7 @@ Returns: Without on_or_after, raw markdown text. With on_or_after, JSON { entrie TOOL_NAMES.VAULT_LIST_MEMORY_FILES, { title: "List Memory Files", - description: `Discovery tool — lists ${config.memoryDir}/ memory files with their H1/H2 heading structure, per-section entry counts, and each file's leading callout (by convention a "Scope of this file" block describing what belongs in it). An entry is a dated bullet line ("- **YYYY-MM-DD**: text"). Does NOT return actual entries. + description: `Discovery tool — lists ${config.memoryDir}/ memory files with each file's heading outline, per-section entry counts, and leading callout. An entry is a dated bullet line ("- **YYYY-MM-DD**: text"). Does NOT return actual entries. Example: vault_list_memory_files() returns [{ file: "Principles", title: "Principles", bytes: 2048, entry_policy: "append-only", leading_callout: null, headings: [{ level: 2, text: "Decision heuristics (newest first)", entry_count: 12 }] }, ...] @@ -174,7 +173,7 @@ Behavior: Lists every .md file directly inside ${config.memoryDir}/, sorted by f Errors: - An empty or nonexistent memory folder returns an empty array, not an error. -Returns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; entry_policy is "append-only" (the default: by convention, entries are never edited or deleted) or "living" (a current-state file whose expired entries may be pruned; declared via \`entry-policy\` frontmatter). The server does not enforce either policy. leading_callout is the file's top-of-file callout ({ type, title, body }), or null.`, +Returns: JSON array of file outlines, each { file, title, bytes, entry_policy, leading_callout, headings } — file is the name the other memory tools take as file (no .md); bytes is the on-disk file size; headings lists H1 and H2 headings in order, with entry_count on H2s; leading_callout is the top-of-file callout ({ type, title, body }; by convention a "Scope of this file" block describing what belongs in the file), or null; entry_policy is "append-only" (the default: by convention, entries are never edited or deleted) or "living" (a current-state file whose expired entries may be pruned; declared via \`entry-policy\` frontmatter). The server does not enforce either policy.`, inputSchema: {}, }, async (_args, extra) => { @@ -199,10 +198,10 @@ Returns: JSON array of file outlines, each { file, title, bytes, entry_policy, l // (EMBEDDING_ENABLED=false) get an honest variant with a synonym-requery // workaround instead of an overclaim. const memoryRecallDescription = config.embeddingEnabled - ? `Recall memory entries about a topic — entry-granular hybrid (keyword + semantic) retrieval across ALL ${config.memoryDir}/ files and ALL time. Returns every relevant dated entry sorted oldest-first, so the full evolution of a preference, opinion, or fact is visible — semantic matching finds early entries even when their phrasing differs from the query. Tuned for recall over precision: expect some marginal entries and judge relevance yourself when synthesizing an answer. Content-word queries ("testing philosophy", "sustainable pacing") rank best; a meta-framed query ("opinions on testing") whose relevance cut would come back empty degrades to relaxed any-term keyword matching instead of returning nothing. + ? `Recall memory entries about a topic — entry-granular hybrid (keyword + semantic) retrieval across ALL ${config.memoryDir}/ files and ALL time. Returns every relevant dated entry sorted oldest-first, so the full evolution of a preference, opinion, or fact is visible — semantic matching finds early entries even when their phrasing differs from the query. Tuned for recall over precision: expect some marginal entries and judge relevance yourself when synthesizing an answer. Content-word queries ("testing philosophy", "sustainable pacing") rank best. A meta-framed query ("opinions on testing") that scores below the relevance threshold degrades to relaxed any-term keyword matching instead of returning nothing. Example: vault_memory_recall({ query: "working hours and pacing" }) -Example: vault_memory_recall({ query: "opinions on testing", file: "Opinions" }) +Example: vault_memory_recall({ query: "testing philosophy", file: "Opinions" }) When to use: Answering "what does my memory say about X?" or "how has my view on Y evolved?" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer. @@ -210,11 +209,11 @@ Errors: - No matching entries returns { entries: [], total: 0 }, not an error - An unknown file returns empty results — call vault_list_memory_files to discover valid names -Returns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included)${recallConsumerClause}. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. search_mode is "hybrid" when vector matching contributed, "fts" when the entries came from keyword matching alone — including the any-term fallback that rescues a would-be-empty result; reranked is true when the cross-encoder relevance cut was applied.` +Returns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included)${recallConsumerClause}. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. file is applied before limit, so limit keeps the most relevant matches from that file. search_mode is "hybrid" when vector matching contributed, "fts" when the entries came from keyword matching alone — including the any-term fallback that rescues a would-be-empty result; reranked is true when the cross-encoder relevance cut was applied.` : `Recall memory entries about a topic — entry-granular keyword retrieval across ALL ${config.memoryDir}/ files and ALL time. Returns every matching dated entry sorted oldest-first, so the evolution of a preference, opinion, or fact reads in order. Matching is stemmed keywords only (semantic matching is off — EMBEDDING_ENABLED=false), and phrasing drifts across months, so re-query with synonyms to cover a topic fully (e.g. "pacing", then "recovery", then "sustainable hours"). A multi-word query whose terms never co-occur in one entry degrades to any-term matching before returning empty. Example: vault_memory_recall({ query: "working hours and pacing" }) -Example: vault_memory_recall({ query: "opinions on testing", file: "Opinions" }) +Example: vault_memory_recall({ query: "testing philosophy", file: "Opinions" }) When to use: Answering "what does my memory say about X?" or "how has my view on Y evolved?" — topic-based recall across memory files. Prefer vault_get_memory to read a known file or section verbatim; prefer vault_search for notes outside the memory layer. @@ -222,7 +221,7 @@ Errors: - No matching entries returns { entries: [], total: 0 }, not an error - An unknown file returns empty results — call vault_list_memory_files to discover valid names -Returns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included)${recallConsumerClause}. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range. search_mode is always "fts" and reranked always false in keyword-only mode.` +Returns: JSON { entries, total, truncated, search_mode, reranked }. Each entry is { file, section, date, text } — text is the raw entry markdown (wikilinks intact, continuation lines included)${recallConsumerClause}. entries ascend by date (oldest first). total counts all matched entries; truncated=true means limit dropped the least-relevant matches — never a date range — so raise limit or narrow the query for the complete set. file is applied before limit, so limit keeps the most relevant matches from that file. search_mode is always "fts" and reranked always false in keyword-only mode.` registerTool( TOOL_NAMES.VAULT_MEMORY_RECALL, @@ -251,9 +250,7 @@ Returns: JSON { entries, total, truncated, search_mode, reranked }. Each entry i .min(1) .optional() .default(50) - .describe( - "Cap on returned entries (default 50). When more match, the least-relevant are dropped and truncated=true — never a date range.", - ), + .describe("Cap on returned entries (default 50)."), }, }, async ({ query, file, limit }, extra) => { @@ -286,26 +283,25 @@ Returns: JSON { entries, total, truncated, search_mode, reranked }. Each entry i TOOL_NAMES.VAULT_UPDATE_MEMORY, { title: "Update Memory", - description: `Append a dated entry to a section of a ${config.memoryDir}/ memory file. The server prefixes the date automatically ("- **YYYY-MM-DD**: entry text") and inserts newest-first by default. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare \`entry-policy: living\` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation. + description: `Append a dated entry to a section of a memory file in ${config.memoryDir}/. The server prefixes the date automatically ("- **YYYY-MM-DD**: entry text") and inserts newest-first by default. Idempotent — an exact duplicate (same date + text in the same section) is a no-op, so retrying a timed-out call is safe. Memory files are append-only by default: when a preference changes, append the new state (newest wins) rather than deleting the old one. A file may declare \`entry-policy: living\` in frontmatter (surfaced by vault_list_memory_files) — a current-state file where pruning expired entries is expected maintenance rather than a violation. Example: vault_update_memory({ file: "Opinions", section: "Code patterns (newest first)", entry: "Prefer immutable data structures" }) When to use: Recording a new preference, principle, opinion, or fact about the user. Call vault_list_memory_files first and reuse existing file and section names so entries stay grouped. -Prefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get "(newest first)" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note). A new section name that is nearly identical to an existing heading (an HTML-entity slip, typo, or spacing variation) is rejected instead of created, so a mistyped name cannot silently fragment the file — names differing only in digits (e.g. "2025" vs "2026") are treated as distinct. +Prefer vault_write_note for creating non-memory notes. A missing file or section is created automatically (new sections get "(newest first)" appended; new files get a placeholder scope callout to fill in via vault_replace_in_note). A new section name nearly identical to an existing heading (an HTML-entity slip, typo, or spacing variation) is rejected, so a mistyped name cannot silently fragment the file — names differing only in digits (e.g. "2025" vs "2026") are distinct. Parameters: -- options.date — ISO YYYY-MM-DD, defaults to today (server timezone). - options.position — "top" (default, newest-first) inserts above existing entries; "bottom" appends below them. Obsidian syntax: Entry text is Obsidian Flavored Markdown. Watch for: #word = tag, [[ = wikilink. Escape with \\# or backticks when unintentional. Errors: -- "refusing memory write: … would shrink content" — safety guard for diverged on-disk content. Re-read with vault_get_memory before retrying. +- "refusing memory write: … would shrink content" — safety guard: the write would leave the file at under half its size, which happens when the file holds content a rewrite cannot keep (for example long YAML comments in its properties). A retry returns the same refusal until a manual edit removes that content; inspect the file with vault_read_note to find it. - "entry must be a single line" — memory entries are single dated bullets; collapse newlines or append multiple entries. - "section must be a single line" — section names become H2 headings; remove line breaks. - "date must be a real ISO calendar date" — options.date only accepts an existing calendar date in bare YYYY-MM-DD form (e.g. "2026-07-02"), not a timestamp. -- "entry/section contains a control character" — entry or section includes a non-printable control byte; remove it before writing. -- "memory file must not start with a dot" — a dot-prefixed name would create a hidden file (invisible in Obsidian and to every listing); choose a visible name. +- "entry contains a control character" / "section contains a control character" — the value includes a non-printable control byte; remove it before writing. +- "memory file must not start with a dot" / "memory file must be a bare name without path separators" — use a bare file name: no folder or slash, and no leading dot (that would create a hidden file, invisible in Obsidian and to every listing). - "section not created: … is nearly identical to existing section …" — near-duplicate guard; pass the exact existing heading (listed in the error) to append there, or choose a clearly different name for a genuinely new section. Returns: Confirmation message (notes when an identical entry already existed and nothing was written).`, @@ -330,7 +326,11 @@ Returns: Confirmation message (notes when an identical entry already existed and ), options: z .object({ - date: z.string().min(1).optional().describe("ISO YYYY-MM-DD date (defaults to today)"), + date: z + .string() + .min(1) + .optional() + .describe("ISO YYYY-MM-DD date (defaults to today, server timezone)"), position: z .enum(["top", "bottom"]) .optional() @@ -382,24 +382,25 @@ Returns: Confirmation message (notes when an identical entry already existed and TOOL_NAMES.VAULT_DELETE_MEMORY, { title: "Delete Memory Entry", - description: `Delete a single dated entry from a ${config.memoryDir}/ memory file. Both date and entry text are required for exact matching — ensures only the intended entry is removed. + description: `Delete a single dated entry from a memory file in ${config.memoryDir}/. Both date and entry text are required for exact matching — ensures only the intended entry is removed. Example: vault_delete_memory({ file: "Opinions", section: "AI tooling & memory (newest first)", date: "2026-05-01", entry: "Prefer X over Y" }) -When to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: when a preference or fact has since evolved, append the new state via vault_update_memory (newest-first naturally supersedes). The exception is a file whose frontmatter declares \`entry-policy: living\` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching. -Prefer vault_update_memory to supersede a changed entry; prefer vault_delete_note for deleting entire non-protected notes. +When to use: Removing an entry that was wrong when it was written — a mistake, a misattribution, or something never true. Memory files are append-only by default, so do NOT delete to reflect a change: append the new state via vault_update_memory instead (newest-first naturally supersedes). The exception is a file whose frontmatter declares \`entry-policy: living\` (check via vault_list_memory_files) — a current-state file where deleting an expired entry is the intended maintenance. Call vault_get_memory(file, section) first to see exact entry text for matching. +Prefer vault_delete_note for deleting entire non-protected notes. Parameters: - date + entry together uniquely identify the bullet line within the given section. If multiple entries share the same date and text, deletion fails as ambiguous. - section scopes the match — an identical entry under a different heading is not found. Section matching is case-insensitive, with or without the "(newest first)" suffix. Errors: -- "memory file must not start with a dot" — a dot-prefixed name would target a hidden file; memory files are always visible notes. +- "memory file not found" — file does not exist in ${config.memoryDir}/; call vault_list_memory_files to discover valid names. +- "memory file must not start with a dot" / "memory file must be a bare name without path separators" — pass the file's bare name: no folder or slash, and no leading dot (that would target a hidden file; memory files are always visible notes). - "date must be a real ISO calendar date" — date only accepts an existing calendar date in bare YYYY-MM-DD form. A hand-edited bullet carrying an impossible date cannot be targeted by this tool — remove it with ${whenToolEnabledText("vault_delete_span", "vault_delete_span or ")}a manual edit. - "section not found: …" — no H2 heading matches; the error lists the file's available sections - "no entry matching …" — no bullet matched the given date and entry text; verify exact text via vault_get_memory(file, section). - "ambiguous: N entries match …" — more than one identical bullet exists in the section (e.g. from hand edits, sync conflicts, or entries predating duplicate protection; vault_update_memory refuses to write exact duplicates). Remove the extra copy with ${whenToolEnabledText("vault_delete_span", "vault_delete_span (pass first_match: true — identical lines make every anchor ambiguous) or ")}a manual edit, then retry. -- "refusing memory write: … would shrink content" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content before retrying. +- "refusing memory write: … would shrink content" — safety guard blocked a write that would remove more than half the file. Re-read with vault_get_memory to confirm current content; an entry that really is that large needs ${whenToolEnabledText("vault_delete_span", "vault_delete_span or ")}a manual edit. Returns: Confirmation message.`, inputSchema: { @@ -423,7 +424,7 @@ Returns: Confirmation message.`, .string() .min(1) .describe( - 'Exact entry text as shown by vault_get_memory — without the "- **YYYY-MM-DD**: " prefix or bullet. Both date and entry must match for deletion.', + 'Exact entry text as shown by vault_get_memory — without the "- **YYYY-MM-DD**: " prefix or bullet.', ), }, }, diff --git a/src/vault-mcp/mcp-core/tools/search-tools.ts b/src/vault-mcp/mcp-core/tools/search-tools.ts index 850180a88..a8c185c24 100644 --- a/src/vault-mcp/mcp-core/tools/search-tools.ts +++ b/src/vault-mcp/mcp-core/tools/search-tools.ts @@ -23,12 +23,8 @@ export const registerSearchTools = ({ Filters — all conditions AND-combine with each other and the text query: - folder: a whole folder, subfolders included — "Projects" covers "Projects/Archive" but not "ProjectsOld/"; ignores ASCII letter case -- tags: require all listed tags (AND) -- type: exact match on frontmatter type (e.g. "person", "session-log") -- related: require all listed related links (AND) -- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: "active" }); values compare by exact type (pass a number as a number, not "4"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value -- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match -- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day +- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: "active" }). Values compare by exact type — pass a number as a number, not "4". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value. +- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter Example: vault_search({ query: "kubernetes networking", filters: { tags: ["reference"] } }) Example: vault_search({ query: "meeting notes", filters: { type: "meeting", folder: "Work" } }) @@ -48,12 +44,8 @@ Returns: JSON with results array (path, title, snippet, score, tags, folder, typ Filters — all conditions AND-combine with each other and the text query: - folder: a whole folder, subfolders included — "Projects" covers "Projects/Archive" but not "ProjectsOld/"; ignores ASCII letter case -- tags: require all listed tags (AND) -- type: exact match on frontmatter type (e.g. "person", "session-log") -- related: require all listed related links (AND) -- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: "active" }); values compare by exact type (pass a number as a number, not "4"), except that checkbox values are stored as 1 and 0, so true matches 1 and false matches 0; a list property matches when any element equals the value -- created: date bounds { before, on, after } in YYYY-MM-DD on the frontmatter created property — before/after are exclusive, on is exact (calendar-day match, server-local). Notes without a parseable created property never match -- modified: date bounds { before, on, after } in YYYY-MM-DD on filesystem modified time (server-local day boundaries) — before/after match strictly earlier/later days, on matches within the day +- properties: arbitrary frontmatter key-value pairs, supports string/number/boolean (e.g. { status: "active" }). Values compare by exact type — pass a number as a number, not "4". Exception: checkbox values are stored as 1 and 0, so pass true to match 1 and false to match 0. A list property matches when any element equals the value. +- created / modified: bounds compare whole calendar days, server-local, so on matches anywhere within the day. Notes without a parseable created property never match a created filter Example: vault_search({ query: "kubernetes networking", filters: { tags: ["reference"] } }) Example: vault_search({ query: "meeting notes", filters: { type: "meeting", folder: "Work" } }) @@ -70,12 +62,7 @@ Errors: Returns: JSON with results array (path, title, snippet, score, tags, folder, type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode ("fts" — keyword-only ranking), and reranked (always false in keyword-only mode). kind is "note" for markdown notes or "file" for non-markdown content (canvas, PDF, and text files — .txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base); file results also carry extension (e.g. ".canvas", ".pdf", ".txt"). created is omitted when null. bytes is the on-disk file size. With include_leading_callout, each result also carries leading_callout ({ type, title, body }) when present.`, inputSchema: { - query: z - .string() - .min(1) - .describe( - "Search query text — unquoted terms use implicit AND with stemming; wrap in double quotes for exact phrases", - ), + query: z.string().min(1).describe("Search query text"), filters: z .object({ folder: z.string().min(1).optional().describe('Restrict to a folder (e.g. "Projects")'), @@ -99,16 +86,14 @@ Returns: JSON with results array (path, title, snippet, score, tags, folder, typ 'Match arbitrary frontmatter properties by key-value (e.g. { status: "active", priority: 1 })', ), created: dateFilterSchema.describe( - 'Created date bounds (YYYY-MM-DD) on the frontmatter "created" property — notes without a parseable value never match', + 'Created date bounds (YYYY-MM-DD) on the frontmatter "created" property', ), modified: dateFilterSchema.describe( "Modified date bounds (YYYY-MM-DD) on filesystem modified time, server-local day boundaries", ), }) .optional() - .describe( - "Optional structured filters — all conditions AND-combine with each other and with the text query", - ), + .describe("Optional structured filters"), limit: z.number().int().min(1).optional().default(20).describe("Max results (default 20)"), snippet_tokens: z .number() @@ -116,13 +101,13 @@ Returns: JSON with results array (path, title, snippet, score, tags, folder, typ .min(1) .optional() .default(30) - .describe("Snippet length in tokens (default 30)"), + .describe("Snippet length in words (default 30)"), include_leading_callout: z .boolean() .optional() .default(false) .describe( - "If true, each result includes its leading_callout ({ type, title, body }) when present. Off by default to keep results lean.", + "If true, include each result's leading callout. Off by default to keep results lean.", ), }, }, @@ -167,19 +152,18 @@ Returns: JSON with results array (path, title, snippet, score, tags, folder, typ 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. -Example: vault_search_by_tag({ tag: "project" }) returns notes tagged project or project/*. +Example: vault_search_by_tag({ tag: "project" }) -When to use: Exploring tag hierarchies or finding notes with a specific tag, without needing a text query. +When to use: Tag-only lookups, for one tag or a whole tag hierarchy, with no text query. Prefer vault_search when you also need text-based relevance ranking. Use vault_list_tags first to discover available tags. Parameters: -- tag is the bare tag name without a leading "#" ("project", not "#project"). Hierarchical tags use "/" separators ("project/vault-cortex"). -- tag + exact interact: with exact=false (default), "project" matches "project", "project/vault-cortex", "project/blog" — the match is prefix-based on the "/" separator, so "project" does NOT match "my-project" or "projects". Set exact=true to match only the literal tag, excluding children. +- 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. Errors: - An unknown tag or no matches returns an empty array, not an error — don't use as an existence check. -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. Promoted keys are in top-level fields; additional_properties contains only unpromoted keys.`, inputSchema: { tag: z .string() @@ -215,14 +199,17 @@ Returns: JSON array of note metadata (path, title, tags, related, folder, type, TOOL_NAMES.VAULT_LIST_TAGS, { title: "List Tags", - description: `List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. "project/vault-cortex") appears as one full entry, not split into segments. Count is unique notes, not occurrences. A vault with no tagged notes returns an empty array. + description: `List all tags in the vault with note counts, ordered by count descending. Only frontmatter tags are counted (inline #tags in note bodies are not indexed). Each hierarchical tag (e.g. "project/vault-cortex") appears as one full entry, not split into segments. Count is unique notes, not occurrences. Example: vault_list_tags() returns [{ tag: "session-log", count: 42 }, { tag: "project/vault-cortex", count: 8 }, ...] When to use: Discovering what tags exist before searching by tag. Good first step for vault orientation. Prefer vault_search_by_tag once you know which tag to query — it supports hierarchical prefix matching ("project" matches "project/*"). -Returns: JSON array of { tag, count } sorted by count descending. tag omits the "#" prefix; count is unique notes with this tag.`, +Errors: +- A vault with no tagged notes returns an empty array, not an error. + +Returns: JSON array of { tag, count }; tag omits the "#" prefix.`, inputSchema: {}, }, async (_args, extra) => { @@ -307,15 +294,14 @@ Prefer vault_list_notes when you only need paths. Prefer vault_search when you h Parameters: - folder names a whole folder, not a text prefix: "Projects" matches notes under "Projects/" but not "ProjectsOld/". Matching ignores ASCII letter case, and a trailing slash is ignored. -- recursive (default true) includes all nested subfolders; set false to list only the folder's top level. -- limit (default 20) applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check. +- limit applies after sorting, so you get the most recently modified notes. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check. Behavior: Reads the search index, which picks up a file change within a few seconds, so a note written moments ago may not appear yet. Notes in hidden (dot-prefixed) folders are never indexed, so never appear. Errors: - An empty or nonexistent folder returns an empty array, not an error. -Returns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file modification time), bytes (on-disk size), plus leading_callout ({ type, title, body }) when the note opens with a callout and additional_properties (its other frontmatter keys) when it has any.`, +Returns: JSON array of note metadata sorted by most recently modified, then by path: path, title, tags, related, folder, type, created (frontmatter; null when missing), modified (file time), bytes (on-disk size), plus, when present, leading_callout (the note's opening callout, { type, title, body }) and additional_properties (other frontmatter keys).`, inputSchema: { folder: z .string() @@ -327,7 +313,7 @@ Returns: JSON array of note metadata sorted by most recently modified, then by p .boolean() .optional() .default(true) - .describe("Include subfolders (default: true)"), + .describe("Include subfolders (default: true); false lists only the folder's top level"), limit: z.number().int().min(1).optional().default(20).describe("Max results (default 20)"), }, }, @@ -548,7 +534,7 @@ Errors: Rejects paths that don't end in .md or .canvas. A non-indexed path retur const fileReadableClause = whenToolEnabledText("vault_read_file", " readable via vault_read_file") const fileBytesClause = whenToolEnabledText( "vault_read_file", - " — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image file is still cheap to read", + " — not the delivery cost: vault_read_file downscales images to fit response limits, so a large image is still cheap to read", ) registerTool( TOOL_NAMES.VAULT_GET_OUTGOING_LINKS, @@ -565,9 +551,11 @@ For incoming links (what links TO a note), use vault_get_backlinks. Parameters: - path: exact vault-relative path including .md or .canvas extension, case-sensitive. Matched against the search index, so the note or canvas must be indexed (file watcher processes new/moved files within seconds). -Returns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind ("note"|"file"): exists+note${whenToolEnabledText("vault_read_note", " = readable via vault_read_note")}; exists+file = non-markdown file (.canvas, image, PDF)${fileReadableClause}; !exists+note = broken link. daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected "create on click" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk file size for notes and files alike (null for broken links)${fileBytesClause}. +Returns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes, daily_note_forward_ref } sorted by target path), and count. Each link carries exists (boolean) and kind ("note"|"file"). When exists is true: kind "note" is a markdown note${whenToolEnabledText("vault_read_note", " readable via vault_read_note")}; kind "file" is a non-markdown file (.canvas, image, PDF)${fileReadableClause}. When exists is false the link is broken (kind is always "note"). daily_note_forward_ref is true on broken links into the vault's daily notes folder — expected "create on click" navigation to a daily note that doesn't exist yet, not genuine breakage. bytes is the on-disk size of a note or file (null for broken links)${fileBytesClause}. -Errors: Rejects paths that don't end in .md or .canvas. A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.`, +Errors: +- "path must end in …" — add the .md or .canvas extension +- A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.`, inputSchema: { path: z .string() @@ -622,20 +610,18 @@ When to use: Vault maintenance — surfacing notes to integrate into the graph.$ Prefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault. Parameters: -- exclude_folders replaces the defaults (${JSON.stringify(config.orphanExcludeFolders)}), it does not add to them — include the defaults yourself to keep them. 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 — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included ("Projects" also excludes "Projects/Archive" but not "ProjectsOld/"), ignoring ASCII letter case. - limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check. Errors: - An empty array means no orphans were found (after exclusions), 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. bytes is the on-disk file size.`, +Returns: 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: { exclude_folders: z .array(z.string().min(1)) .optional() - .describe( - `Folders to exclude — replaces the defaults (${JSON.stringify(config.orphanExcludeFolders)}), not merged`, - ), + .describe(`Folders to exclude (default ${JSON.stringify(config.orphanExcludeFolders)})`), limit: z.number().int().min(1).optional().default(50).describe("Max results (default 50)"), }, }, diff --git a/src/vault-mcp/mcp-core/tools/task-tools.ts b/src/vault-mcp/mcp-core/tools/task-tools.ts index 354223378..cbe8de22a 100644 --- a/src/vault-mcp/mcp-core/tools/task-tools.ts +++ b/src/vault-mcp/mcp-core/tools/task-tools.ts @@ -19,32 +19,30 @@ export const registerTaskTools = ({ TOOL_NAMES.VAULT_LIST_TASKS, { title: "List Tasks", - description: `List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes the Tasks plugin's status registry types NON_TASK are also excluded. + description: `List checkbox tasks across the whole vault with structured filters — the Tasks-plugin data model over MCP. Both task metadata formats are indexed: emoji signifiers (📅 due, ⏳ scheduled, 🛫 start, ➕ created, ✅ done, ❌ cancelled, 🔺⏫🔼🔽⏬ priority, 🔁 recurrence, 🏁 onCompletion, 🆔/⛔ dependencies) and Dataview inline fields ([due:: 2026-07-04], [priority:: high], ...). Every result carries its attribution — note path, folder, line number, and the nearest heading when the task sits under one (the lane on a Kanban board) — so no follow-up reads are needed to locate a task. Task lines inside fenced code blocks and %% %% comment blocks are not indexed. Checkboxes typed NON_TASK in the Tasks plugin's status registry are also excluded. Example: vault_list_tasks({ due: { before: "2026-07-04" } }) — overdue triage; the default status (not_done) and sort (due ascending) make this the "what's overdue?" call -Example: vault_list_tasks({ path: "Code Projects/vault-cortex/TASKS.md", heading: ["Active", "Up Next", "Waiting On"], sort_by: "position" }) — actionable Kanban lanes in board order; position is the natural sort for boards (file path then line number, preserving card arrangement) -Example: vault_list_tasks({ folder: "Code Projects/vault-cortex" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories); folder includes its subfolders +Example: vault_list_tasks({ path: "Code Projects/vault-cortex/TASKS.md", heading: ["Active", "Up Next", "Waiting On"], sort_by: "position" }) — actionable Kanban lanes in board order +Example: vault_list_tasks({ folder: "Code Projects/vault-cortex" }) — all open tasks across a project tree (TASKS.md + task-notes/ subdirectories) Example: vault_list_tasks({ status: "done", done: { after: "2026-06-26" } }) — what got completed this week Example: vault_list_tasks({ top_level_only: true, path: "TASKS.md" }) — board cards only, excluding checklist sub-items -When to use: Any vault-wide task triage question — "what's overdue?", "what's open per project?", "what did I finish this week?" — in one call instead of per-board reads. -Prefer vault_read_note (heading mode) to read one specific board lane verbatim. Prefer vault_search for full-text queries over note content. +When to use: Reading task status and order on one board (path + sort_by: "position"; add status: "all" to include done and cancelled cards). Also any vault-wide task triage question — "what's overdue?", "what's open per project?", "what did I finish this week?" — in one call instead of per-board reads. +${whenToolEnabledText("vault_read_note", "Prefer vault_read_note (heading mode) only when you need a lane's verbatim Markdown or a task's state right after a write. ")}Prefer vault_search for full-text queries over note content. + +Behavior: Reads the search index, which picks up a file change within a few seconds, so a task written moments ago may still show its old state. Parameters: -- status: a single value or an array of values, OR-combined (default "not_done"). Values: "not_done" (todo + in_progress, excludes done AND cancelled), "todo", "in_progress", "done", "cancelled", "all". Virtual values expand in arrays: ["not_done", "done"] matches todo + in_progress + done. -- due / scheduled / start / done / created / cancelled: date filters, each { before, on, after } in YYYY-MM-DD — before/after are exclusive, on is exact. A date filter only matches tasks that HAVE that date. -- priority: array of "highest" | "high" | "medium" | "low" | "lowest" | "none", OR-combined ("none" = tasks with no priority signifier). -- folder: a whole folder, subfolders included ("Projects" covers "Projects/Archive" but not "ProjectsOld/"), ignoring ASCII letter case. tag: bare inline-task-tag name; a parent tag matches children. heading: exact heading text or array of headings, case-sensitive, OR-combined. path: one note, must end in ".md". -- top_level_only: boolean (default false). When true, only top-level tasks (depth 0) are returned — excludes indented sub-tasks and checklist items. -- sort_by: "due" (default) | "scheduled" | "start" | "created" | "done" | "priority" | "note_mtime" | "position". "position" sorts by file path then line number — the natural order for Kanban boards. -- limit: max results (default 50). The total field always reports the full match count. +- status: virtual values expand in arrays — ["not_done", "done"] matches todo + in_progress + done. +- due / scheduled / start / done / created / cancelled: a date filter only matches tasks that HAVE that date. +- folder: a whole folder, subfolders included ("Projects" covers "Projects/Archive" but not "ProjectsOld/"), ignoring ASCII letter case. Errors: - A malformed or calendar-invalid date filter throws with remediation text ("Use YYYY-MM-DD") - path without the ".md" extension is rejected - No matches returns { total: 0, tasks: [] }, not an error -Returns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist (absent = no checklist items); done counts status "done" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.`, +Returns: JSON { total, tasks }. Every task carries path, line, status, status_char, description, folder, depth (0 for top-level, 1+ for sub-tasks), is_kanban_task, depends_on, and tags (the arrays are [] when empty). Every other field appears only when the task has it: heading (nearest heading above the task), created/scheduled/start/due/done/cancelled dates, priority, recurrence, on_completion, task_id, block_id, parent_block_id (sub-tasks whose parent carries a ^block-id), done_lanes (Kanban boards only), and subtask_progress — { done, total } over the task's DIRECT checklist children, present only when the task has a checklist; done counts status "done" only (a cancelled child counts toward total, not done), and the counts ignore the query's filters — so a filtered or top_level_only read still shows each card's checklist progress.`, inputSchema: { status: z .union([ @@ -54,7 +52,7 @@ Returns: JSON { total, tasks }. Every task carries path, line, status, status_ch .optional() .default("not_done") .describe( - 'Status filter, OR-combined (default "not_done" = todo + in_progress, excluding done and cancelled). Virtual values expand in arrays: "not_done" adds todo + in_progress, "all" includes every status.', + 'Status filter, OR-combined (default "not_done" = todo + in_progress, excluding done and cancelled). "all" includes every status.', ), due: dateFilterSchema.describe("Due date (📅 / [due:: ]) bounds"), scheduled: dateFilterSchema.describe("Scheduled date (⏳ / [scheduled:: ]) bounds"), @@ -64,6 +62,7 @@ Returns: JSON { total, tasks }. Every task carries path, line, status, status_ch cancelled: dateFilterSchema.describe("Cancelled date (❌ / [cancelled:: ]) bounds"), priority: z .array(z.enum(["highest", "high", "medium", "low", "lowest", "none"])) + .min(1) .optional() .describe( 'Priority levels, OR-combined; "none" selects tasks with no priority signifier', @@ -117,7 +116,7 @@ Returns: JSON { total, tasks }. Every task carries path, line, status, status_ch .optional() .default("due") .describe( - 'Sort key (default "due"). Date sorts cascade through related fields when the primary is absent; each fallback uses its own natural direction. "position" sorts by file path then line number — the natural order for Kanban boards.', + 'Sort key (default "due"). Date sorts cascade through related fields when the primary is absent (through the rest of due → scheduled → start → created, in that order; done does not cascade); each fallback uses its own natural direction. "position" sorts by file path then line number — the natural order for Kanban boards.', ), sort_direction: z .enum(["asc", "desc"]) @@ -218,51 +217,42 @@ Returns: JSON { total, tasks }. Every task carries path, line, status, status_ch description: `Create a correctly-formatted task in one call — description, target heading, dates, priority, block_id, and optional checklist sub-items. The task is created as todo (using the status registry's todo symbol, [ ] by default) with ➕ today auto-stamped${whenToolEnabledText("vault_update_task", " — starting work is vault_update_task's job")}. Metadata is written in the format the vault's Tasks plugin is configured for (emoji unless the plugin config says Dataview). Example: vault_create_task({ path: "TASKS.md", description: "Fix login bug", block_id: "fix-login", heading: "Active", priority: "high", due: "2026-09-15" }) -Example: vault_create_task({ path: "TASKS.md", description: "Ship the feature", block_id: "ship-feature", heading: "Up Next", subtasks: ["Design", "Implement", "Test"] }) — card with checklist stages Example: vault_create_task({ path: "TASKS.md", description: "Sub-bug", block_id: "sub-bug", parent_block_id: "fix-login", due: "2026-09-01" }) — full sub-task under a parent identified by block_id Example: vault_create_task({ path: "TASKS.md", description: "Quick fix", block_id: "quick-fix", parent_line: 42 }) — sub-task under a parent identified by line number -Example: vault_create_task({ path: "TASKS.md", description: "Urgent fix", block_id: "urgent-fix", heading: "Active", position: "top" }) — insert at the top of a lane instead of the default bottom -Example: vault_create_task({ path: "TASKS.md", description: "Mid-priority", block_id: "mid-priority", heading: "Active", position: 3 }) — insert as the 3rd card in the lane (1-based; past the card count lands directly below the last card) +Example: vault_create_task({ path: "TASKS.md", description: "Mid-priority", block_id: "mid-priority", heading: "Active", position: 3 }) — insert as the 3rd card in the lane When to use: Creating a new task card on a board or in a note. Guarantees correct field ordering (description → priority → 🔁 recurrence → 🏁 onCompletion → ➕ created → 🛫 start → ⏳ scheduled → 📅 due → 🆔 task_id → ⛔ depends_on → ^block_id)${whenToolEnabledText("vault_list_tasks", " so the card round-trips through vault_list_tasks with all fields intact")}.${whenToolEnabledText("vault_update_task", " For lightweight checklist items under an existing card (no metadata), use vault_update_task's add_subtasks param instead.")} Parameters: -- path (required): vault-relative path to the note (must end in ".md"). The note must already exist. -- description (required): the task text (before metadata fields). -- block_id (required): the ^block-id for stable identification — letters, digits, and hyphens only. Must be unique within the note. -- heading: target heading. Required on Kanban boards (notes with kanban-plugin frontmatter); optional on regular notes (omit to append at end of body). -- parent_block_id / parent_line: the existing task to nest under as a sub-task, identified by its ^block-id or its 1-based line number — the same pair vault_update_task uses (block_id / line). Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives. -- position: "top", "bottom", or a 1-based integer — where within the heading section the task is placed. "top" or "bottom" for the extremes; an integer for an exact slot among the lane's top-level cards (position 1 is the first card; past the card count lands directly below the last card). Defaults to "bottom" (append). Kanban boards with new-card-insertion-method set to "prepend" default to "top" instead. Ignored when no heading or when placing under a parent. -- priority: "highest" | "high" | "medium" | "low" | "lowest". Omit for normal priority (the plugin ranks "no signifier" between medium and low). -- recurrence: a Tasks plugin 🔁 rule in natural language ("every week", "every month on the 15th", "every 3 days when done" — "when done" bases the next occurrence on the completion day). Completing the task later spawns its next occurrence automatically. -- on_completion: "delete" or "keep" — sets the Tasks plugin 🏁 action applied when the task is completed. "delete" removes the task line on completion; "keep" leaves it in place. -- due / scheduled / start: YYYY-MM-DD dates (calendar-validated). Omit a date rather than guessing — an absent 📅 means "no deadline". -- task_id: Tasks plugin 🆔 identifier for dependency chains. -- depends_on: non-empty string array of Tasks plugin ⛔ dependency IDs (🆔 values of other tasks). -- subtasks: string array of checklist item descriptions — created as indented todo lines under the card (no metadata, no block_ids). For full sub-tasks with their own dates, priority, and block_id, make a separate vault_create_task call with parent_block_id. -- format: "emoji" or "dataview" — overrides the auto-detected Tasks plugin format (emoji when no plugin config is present). +- heading is required on Kanban boards (notes with kanban-plugin frontmatter). +- parent_block_id / parent_line: ${whenToolEnabledText("vault_update_task", "the same pair vault_update_task uses (block_id / line). ")}Pass at most one. Either is mutually exclusive with heading — a sub-task lives wherever its parent lives. +- position: Kanban boards with new-card-insertion-method set to "prepend" default to "top" instead of "bottom". Ignored when no heading or when placing under a parent. +- priority: the plugin ranks "no signifier" (normal priority) between medium and low. +- recurrence: a rule ending "when done" bases the next occurrence on the completion day. +- due / scheduled / start: omit a date rather than guessing — an absent 📅 means "no deadline". Errors: - "note not found" — path does not exist - "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 - "heading required for Kanban boards" — kanban-plugin note without heading - "heading "X" not found; available: ..." — no heading matches; the error lists the note's headings - "cannot place at position N under "X" — the heading appears N times" — integer position on a note with duplicate heading names; rename one section to make it unique - "parent task not found" — parent_block_id or parent_line doesn't resolve to a task (message names the blockId or line tried), or the line is inside a fenced code block or %% %% comment -- "checkbox '[c]' is a NON_TASK status" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server +- "checkbox "[c]" is a NON_TASK status" — the parent task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server - "no checkbox symbol for status ..." — the status registry has no symbol for the todo status and the built-in default is retyped; update the plugin's status registry to include a todo symbol, then restart the server - "parentBlockId and parentLine are mutually exclusive" — both parent_block_id and parent_line were passed; drop one - "parent and heading are mutually exclusive" — a parent (parent_block_id or parent_line) and heading were both passed; drop one - "blockId ... already exists in this note" — pick a block_id not yet used in the note - "blockId ... contains invalid characters" — block_id must match [a-zA-Z0-9-]+ -- "description is empty" / "dependsOn cannot be empty" / "subtasks cannot contain an empty item" — whitespace-only description, an empty depends_on array, or a whitespace-only checklist item +- "description is empty" / "subtasks cannot contain an empty item" — whitespace-only description or checklist item - "description must be a single line" / "subtasks items must be a single line" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads - "taskId ... contains invalid characters" / "dependsOn entry ... contains invalid characters" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar) - "unrecognized recurrence rule ..." — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur - "invalid date" — a date param fails calendar validation - "concurrent write in progress" — another write to this note is in flight; retry -Obsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or subtasks text — an emoji field like "🔁 every week", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence. +Obsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or subtasks text (an emoji field like "🔁 every week", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than submitted, the result carries an advisories array naming each divergence. Returns: JSON { path, line, description, block_id, heading, subtasks, changes, advisories } — line is the new card's 1-based position; heading is the nearest heading above the new task (omitted when the note has none); subtasks lists each checklist item written as { line, description } (omitted when none) — checklist items carry no block_id, so line is the handle for a follow-up update; changes lists every field written as "field: before → after", with "(none)" for an absent value; advisories (omitted when the line round-trips clean) lists one sentence per place the stored line parses back differently than submitted — see Obsidian syntax above.`, inputSchema: { @@ -283,29 +273,25 @@ Returns: JSON { path, line, description, block_id, heading, subtasks, changes, a .string() .min(1) .optional() - .describe( - "Target heading. Required on Kanban boards; optional on regular notes (omit to append at end of body).", - ), + .describe("Target heading. On a regular note, omit to append at end of body."), parent_block_id: z .string() .min(1) .optional() - .describe( - "^block-id (without the ^) of an existing task to nest under as a sub-task. Mutually exclusive with parent_line and heading.", - ), + .describe("^block-id (without the ^) of an existing task to nest under as a sub-task."), parent_line: z .number() .int() .min(1) .optional() .describe( - "1-based line number of an existing task to nest under as a sub-task. Mutually exclusive with parent_block_id and heading. Fragile if the file changed since the line was read.", + "1-based line number of an existing task to nest under as a sub-task. Fragile if the file changed since the line was read.", ), position: z .union([z.enum(["top", "bottom"]), z.number().int().min(1)]) .optional() .describe( - 'Where within the heading section the task is placed. "top" or "bottom" for the extremes; an integer (1-based) for an exact position among the lane\'s top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike "bottom", which appends after any trailing section content). Defaults to bottom. Kanban boards with new-card-insertion-method set to prepend default to top instead. Ignored when no heading or when placing under a parent.', + 'Where within the heading section the task is placed. "top" or "bottom" for the extremes; an integer (1-based) for an exact position among the lane\'s top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike "bottom", which appends after any non-task text at the end of the section). Defaults to bottom.', ), priority: z .enum(["highest", "high", "medium", "low", "lowest"]) @@ -318,7 +304,7 @@ Returns: JSON { path, line, description, block_id, heading, subtasks, changes, a .min(1) .optional() .describe( - 'Tasks plugin 🔁 rule in natural language (e.g. "every week", "every 2 weeks when done"). Completing the task spawns its next occurrence.', + 'Tasks plugin 🔁 rule in natural language (e.g. "every week", "every month on the 15th", "every 2 weeks when done"). Completing the task spawns its next occurrence.', ), on_completion: z .enum(["delete", "keep"]) @@ -467,49 +453,34 @@ Returns: JSON { path, line, description, block_id, heading, subtasks, changes, a Example: vault_update_task({ path: "TASKS.md", block_id: "my-task", status: "done" }) — complete a task; on a Kanban board, auto-moves to the done lane; a recurring task (🔁) spawns its next occurrence Example: vault_update_task({ path: "TASKS.md", block_id: "my-task", recurrence: "every week" }) — make a task recurring (null removes the rule) Example: vault_update_task({ path: "TASKS.md", block_id: "my-task", heading: "Done" }) — move a task to a different heading (lands at the top of the lane by default) -Example: vault_update_task({ path: "TASKS.md", block_id: "my-task", heading: "Done", position: "bottom" }) — move to the bottom of the lane -Example: vault_update_task({ path: "TASKS.md", block_id: "my-task", description: "Updated task name", due: "2026-10-01" }) — change description and set due date -Example: vault_update_task({ path: "TASKS.md", block_id: "my-task", due: null }) — clear a date field +Example: vault_update_task({ path: "TASKS.md", block_id: "my-task", description: "Updated task name", due: "2026-10-01", scheduled: null }) — change the description, set one date, and clear another Example: vault_update_task({ path: "TASKS.md", block_id: "my-task", status: "in_progress", add_subtasks: ["Design", "Implement", "Test"] }) — start working and add checklist stages Example: vault_update_task({ path: "TASKS.md", line: 42, assign_block_id: "my-task" }) — add a block_id to a task that lacks one -Example: vault_update_task({ path: "TASKS.md", block_id: "my-task", task_id: "abc123" }) — set a Tasks plugin 🆔 identifier -Example: vault_update_task({ path: "TASKS.md", block_id: "my-task", on_completion: "delete" }) — set the task to be removed on completion (null clears the field) -Example: vault_update_task({ path: "TASKS.md", block_id: "my-task", heading: "Active", position: 3 }) — move to the 3rd position in a lane (1-based; past the card count lands directly below the last card) -Example: vault_update_task({ path: "TASKS.md", block_id: "my-task", position: 1 }) — same-lane reorder to the top without a heading move +Example: vault_update_task({ path: "TASKS.md", block_id: "my-task", heading: "Active", position: 3 }) — move to the 3rd position in a lane When to use: Any change to an existing task — completing, starting, re-prioritizing, editing text, setting or clearing dates, adding checklist items, assigning block_ids, moving between headings, or reordering within a lane.${whenToolEnabledText("vault_list_tasks", " Use vault_list_tasks first to get identification fields (path + block_id or line).")}${whenToolEnabledText("vault_create_task", " For creating a new task, use vault_create_task instead.")} Parameters: -- path (required): vault-relative path to the note (must end in ".md"). - Exactly one of block_id or line is required to identify the task. -- At least one change is required. Every field passed is applied in the same single write: - - status: "todo" | "in_progress" | "done" | "cancelled". Manages checkbox and done/cancelled dates. - Kanban: "done" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent. - Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's "next line" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id. - Delete (🏁): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with "next line", children transfer to the spawn. Result carries on_completion_applied: "delete". - - priority: "highest" | "high" | "medium" | "low" | "lowest" sets the signifier; null removes it. - - recurrence: sets the Tasks plugin 🔁 rule, in natural language ("every week", "every month on the 15th", "every 3 days when done" — "when done" bases the next occurrence on the completion day); null removes it. Passed together with status "done", the new rule governs the spawn (recurrence: null completes without spawning). - - on_completion: "delete" or "keep" — sets the Tasks plugin 🏁 action applied when the task is completed; null removes it. When on_completion and status are passed in the same call, the submitted on_completion value governs the delete decision — setting "keep" while completing a "delete" task prevents the deletion. See the status bullet for delete behavior details. - - description: replaces the task text. Metadata fields and block_id are preserved. - - due / scheduled / start / created: YYYY-MM-DD sets the date; null clears it. - - task_id: string sets the Tasks plugin 🆔; null clears it. - - depends_on: non-empty string array sets the Tasks plugin ⛔; null clears it. - - add_subtasks: non-empty string array — appends one indented todo checklist item per entry under the task; existing checklist items are kept.${whenToolEnabledText("vault_create_task", " For full sub-tasks with their own metadata, use vault_create_task with parent_block_id.")} - - assign_block_id: adds or replaces the ^block-id on the task line. Letters, digits, and hyphens only; must be unique within the note. - - heading: target heading to move the task to. On Kanban boards this is a lane move; works on any note with headings. Not valid on sub-tasks. - - position: "top", "bottom", or a 1-based integer — where within the target heading the task lands after a heading move or auto-done-lane move. Position 1 is the first card; past the card count lands directly below the last card. Defaults to "top" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks. - - Clearing is always explicit null — omitting a field leaves it untouched. -- format: "emoji" or "dataview" — overrides the auto-detected Tasks plugin format. +- At least one change is required. Clearing is always explicit null — omitting a field leaves it untouched. +- status manages the checkbox and the done/cancelled dates. + Kanban: "done" moves the card and its checklist sub-items to the done lane (sub-item checkboxes left as they are); a sub-task stays under its parent. + Recurring (🔁): spawns the next occurrence above the completed one (below with the plugin's "next line" setting), dates advanced per the rule. The spawn stays in the source lane with no block_id, 🆔, or ⛔ — follow up with assign_block_id on next_occurrence.line. Completing by line is NOT idempotent for recurring tasks (the spawn occupies the old line); prefer block_id. + Delete (🏁 delete / [onCompletion:: delete]): removes the task line and children instead of moving to done. With 🔁 + 🏁, the spawn is created first, then the completed line is removed; with "next line", children transfer to the spawn. Result carries on_completion_applied: "delete". +- recurrence: a rule ending "when done" bases the next occurrence on the completion day. When set together with status "done", the new rule governs the spawn. Setting recurrence to null while completing removes the rule and completes without spawning. +- on_completion: passed together with status, the submitted value governs the delete decision — "keep" while completing a "delete" task prevents the deletion. +- position: applies to a heading move or an auto-done-lane move. Without a heading, it triggers a same-lane reorder to the given position; omitting position performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks. Errors: - "note not found" — path does not exist - "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 - "exactly one of blockId or line is required" / "blockId and line are mutually exclusive" — pass exactly one of block_id or line - "blockId ... not found" — no task line in the note ends with ^block_id - "blockId ... is inside a fenced code block or comment" — the block_id matches a line inside a fenced code block or %% %% comment; target a line outside the fence - "no task at line N" — line doesn't contain a task checkbox - "line N is inside a fenced code block or comment" — the line is inside a fenced code block or %% %% comment; target a line outside the fence -- "checkbox '[c]' is a NON_TASK status" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server +- "checkbox "[c]" is a NON_TASK status" — the task's checkbox char is typed NON_TASK in the Tasks plugin's status registry, so it is not a task; to change that, retype it there and restart the server - "no checkbox symbol for status ..." — the status registry has no symbol for the target status and the built-in default is retyped; update the plugin's status registry to include a symbol for this status, then restart the server - "at least one mutation" — no change params provided - "cannot move a sub-task to a heading" — explicit heading on a task nested under another task (depth > 0${whenToolEnabledText("vault_list_tasks", " in vault_list_tasks")}) @@ -522,15 +493,15 @@ Errors: - "no done lane detected" — status "done" on a Kanban board with no **Complete** marker and no "Done" heading; pass heading explicitly - "blockId ... already exists" / "blockId ... contains invalid characters" — assign_block_id must be unique in the note and match [a-zA-Z0-9-]+ - "invalid date" — a date param fails calendar validation -- "description cannot be empty" / "dependsOn cannot be empty" / "addSubtasks cannot be empty" / "addSubtasks cannot contain an empty item" — whitespace-only text or an empty array (use null to clear depends_on) +- "description cannot be empty" / "addSubtasks cannot contain an empty item" — whitespace-only description or checklist item - "description must be a single line" / "addSubtasks items must be a single line" — a task is one file line; a line break in the text would split its metadata onto a line the parser never reads - "taskId ... contains invalid characters" / "dependsOn entry ... contains invalid characters" — task_id and every depends_on entry must match [a-zA-Z0-9_-]+ (the Tasks plugin's id grammar) - "unrecognized recurrence rule ..." — the rule text is not Tasks-plugin natural language; written as-is it would silently never recur - "concurrent write in progress" — another write to this note is in flight; retry -Obsidian syntax: The Tasks plugin reads metadata off the END of a task line, so a trailing run of signifier syntax inside description or add_subtasks text — an emoji field like "🔁 every week", or a Dataview [key:: value] field, followed only by other recognized task fields — is read back as task metadata rather than text. A signifier followed by ordinary prose stays description text unless the prose matches that field's value grammar — a 🔁 recurrence reads any trailing words as its rule, while a 📅 followed by ordinary words stays description text because the words are not a date. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set, as the 🔁 example shows. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) are expected and produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported. +Obsidian syntax: The Tasks plugin reads metadata off the END of a task line. A trailing signifier in description or add_subtasks text (an emoji field like "🔁 every week", or a Dataview [key:: value] field) that the plugin's parser recognizes as a field — followed only by other recognized fields — is read back as metadata, not text. Whether it is captured depends on the field's value grammar: 🔁 reads any trailing words as its recurrence rule, while 📅 followed by non-date words stays description text. The same interference can change the value an adjacent field reads back with, or make a field appear that was never set. The write still succeeds either way; when the stored line would read back differently than this call set, the result carries an advisories array naming each divergence. The dates a status change stamps or clears (the ✅/❌ dates) produce no advisories on their own — but a description signifier that changes what the stamped date parses back as is still reported. -Returns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is "delete", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as "field: before → after", with "(none)" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds "next_occurrence: (none) → line N"); advisories (omitted when there are none) lists one sentence per place the stored line parses back differently than this call set (see Obsidian syntax above), per duplicate tag removed from the line, or per completed recurring task whose rule yields no next occurrence (for example, unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always "delete".`, +Returns: JSON { path, line, description, block_id, heading, subtasks, next_occurrence, changes, advisories, on_completion_applied } — line is the final 1-based position (when on_completion_applied is "delete", it is the position the task occupied before removal); description is the current text; block_id and heading reflect the task after the update (block_id is omitted when the task has none, heading when the task sits above the first heading); subtasks lists each checklist item added by add_subtasks as { line, description } (omitted when none were added) — checklist items carry no block_id, so line is the handle for a follow-up update; next_occurrence is present only when a completion spawned a recurring task's next occurrence: { line, description, due?, scheduled?, start? } with only the dates the occurrence has — it carries no block_id, so line is its handle; changes lists every field applied as "field: before → after", with "(none)" for an absent value (for subtasks the two sides are checklist-item counts, and a spawn adds "next_occurrence: (none) → line N"); advisories (omitted when there are none) lists one sentence for each: a stored line that parses back differently than submitted (see Obsidian syntax above), a duplicate tag removed from the line, or a completed recurring task whose rule yields no next occurrence (unreadable rule text or a rule with no dates left); on_completion_applied (present only when the effective on_completion was delete — pre-existing on the task or set in the same call — and it was transitioned to done) is always "delete".`, inputSchema: { path: z .string() @@ -556,9 +527,7 @@ Returns: JSON { path, line, description, block_id, heading, subtasks, next_occur status: z .enum(["todo", "in_progress", "done", "cancelled"]) .optional() - .describe( - 'Target status. "done" appends the ✅ date and, on a Kanban board, moves the card and its checklist sub-items to the done lane (sub-item checkboxes are left as they are); a task with 🏁 delete / [onCompletion:: delete] is removed from the file instead. "cancelled" appends the ❌ date.', - ), + .describe('Target status. "done" appends the ✅ date; "cancelled" appends the ❌ date.'), priority: z .enum(["highest", "high", "medium", "low", "lowest"]) .nullable() @@ -570,7 +539,7 @@ Returns: JSON { path, line, description, block_id, heading, subtasks, next_occur .nullable() .optional() .describe( - 'Tasks plugin 🔁 rule in natural language (e.g. "every week", "every 2 weeks when done") to set, or null to remove it. Completing the task spawns its next occurrence.', + 'Tasks plugin 🔁 rule in natural language (e.g. "every week", "every month on the 15th", "every 2 weeks when done") to set, or null to remove it. Completing the task spawns its next occurrence.', ), on_completion: z .enum(["delete", "keep"]) @@ -649,7 +618,7 @@ Returns: JSON { path, line, description, block_id, heading, subtasks, next_occur .union([z.enum(["top", "bottom"]), z.number().int().min(1)]) .optional() .describe( - 'Where within the target heading the task lands after a heading move or auto-done-lane move. "top" or "bottom" for the extremes; an integer (1-based) for an exact position among the lane\'s top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike "bottom", which appends after any trailing section content). Defaults to "top" on heading moves. Without a heading, triggers a same-lane reorder to the given position; omitting position entirely performs no reorder. Ignored when the task is deleted on completion. Not valid on sub-tasks.', + 'Where within the target heading the task lands. "top" or "bottom" for the extremes; an integer (1-based) for an exact position among the lane\'s top-level cards (sub-tasks move with their parent and are not counted). Position 1 is the first card. A position past the card count lands directly below the last card (unlike "bottom", which appends after any non-task text at the end of the section). Defaults to "top" on heading moves.', ), format: z .enum(["emoji", "dataview"]) 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 d5fbfb698..08a083034 100644 --- a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts +++ b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts @@ -90,7 +90,7 @@ Example: vault_read_note({ path: "TASKS.md", heading: "Done", heading_level: 2 } Example: vault_read_note({ path: "TASKS.md", heading: "Done", start_line: 1, limit: 20 }) // first 20 lines of an oversized section When to use: You know the exact path and need a specific note's content. For a large note (a long board or doc), use outline: true to see its headings and any text sitting above them, then heading: "..." to read just the one section you need — both far cheaper than pulling the whole file. Use properties_only: true when you only need properties. For an oversized note or section, page it with start_line and limit to read a window at a time. To check a note's or section's line count, request start_line: 1 with limit: 1 — one line plus the total. -Prefer vault_search when you don't know the path.${whenToolEnabledText("vault_get_memory", ` Prefer vault_get_memory for ${config.memoryDir}/ files (returns content without properties).`)}${whenToolEnabledText("vault_patch_note", " To edit a section you've read, use vault_patch_note.")} To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links. +Prefer vault_search when you don't know the path.${whenToolEnabledText("vault_list_tasks", " For task status or order on a board, prefer vault_list_tasks; heading mode returns a lane's verbatim Markdown.")}${whenToolEnabledText("vault_get_memory", ` Prefer vault_get_memory for ${config.memoryDir}/ files (returns content without properties).`)}${whenToolEnabledText("vault_patch_note", " To edit a section you've read, use vault_patch_note.")} To explore what links to this note or what it links to, use vault_get_backlinks and vault_get_outgoing_links. Section boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included. Modes are mutually exclusive — set at most one of properties_only, outline, or heading. Paged reads normalize line endings to LF; unpaged reads stay byte-identical. @@ -99,14 +99,16 @@ Errors: - "heading not found" — no heading matches the text; error lists available headings - "ambiguous heading" — multiple headings match; use heading_level to disambiguate, or read the full note (omit heading) when headings share the same level - "outline, heading, and properties_only are mutually exclusive" — only one mode per call +- "heading_level requires a heading" — heading_level only disambiguates a heading; pass heading with it +- "heading cannot be empty" — heading is whitespace only; pass the heading's text - "line paging is not available in outline mode" / "... properties_only mode" — start_line/limit only work on text renditions (full read or heading section) - "start line past the end" — start_line exceeds the rendition's line count; error states the total - 'path must end in ".md"' — the path names a non-markdown file${whenToolEnabledText("vault_read_file", "; read files (images, .canvas, data files) with vault_read_file instead")} -- "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like ".obsidian/"; use a vault-relative path outside hidden folders +- "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it -Returns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object with file-level bytes and modified time (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block ("path — lines 1–20 of 250 (continue with start_line: 21)"). +Returns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object, shaped as the outline parameter describes (outline); raw markdown of the section, heading line included (heading). When start_line or limit is given, the result is preceded by a window-metadata text block ("path — lines 1–20 of 250 (continue with start_line: 21)"). -Outline shape: { bytes, modified, leading_callout?, leading_content?, headings } — bytes is the whole file's on-disk size; modified is its filesystem modification time; headings is [{ level, text, bytes }], where each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. leading_callout ({ type, title, body }) is the note's top-of-file callout; leading_content is the rest of the body text above the first heading, with the callout's own lines excluded so the two never repeat the same text. Either key is omitted when the note has none. Empty headings ("##" with no text) appear with text: "" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note${whenToolEnabledText("vault_replace_in_note", ", and edit via vault_replace_in_note")}.`, +Outline: bytes at the root is the whole file's on-disk size and modified is its filesystem modification time; each heading's bytes is the exact UTF-8 byte length of the text that heading mode returns for that section. Empty headings ("##" with no text) appear with text: "" — they act as section boundaries but cannot be targeted by the heading parameter; read the parent section (which includes child headings) or the full note${whenToolEnabledText("vault_replace_in_note", ", and edit via vault_replace_in_note")}.`, inputSchema: { path: z .string() @@ -128,9 +130,7 @@ Outline shape: { bytes, modified, leading_callout?, leading_content?, headings } .string() .min(1) .optional() - .describe( - "Return only this section (heading line + body, through the next same-or-higher heading). Case-sensitive exact match.", - ), + .describe("Return only this section. Case-sensitive exact match."), heading_level: z .number() .int() @@ -146,16 +146,14 @@ Outline shape: { bytes, modified, leading_callout?, leading_content?, headings } .min(1) .optional() .describe( - "First line to return, 1-based (default 1). Pages the delivered rendition (full body or a heading section). Not valid for outline or properties_only (JSON modes).", + "First line to return, 1-based (default 1). Pages the text output (full body or a heading section). Not valid for outline or properties_only (JSON modes).", ), limit: z .number() .int() .min(1) .optional() - .describe( - "Maximum lines returned (default: all remaining). A paged read's metadata line states the window, the total line count, and the next start_line.", - ), + .describe("Maximum lines returned (default: all remaining)."), }, }, async ( @@ -377,7 +375,7 @@ Returns: JSON array of vault-relative path strings (e.g. ["Notes/idea.md", "Proj TOOL_NAMES.VAULT_WRITE_NOTE, { title: "Write Note", - description: `Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content — this is a full write, not a partial edit. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved). + description: `Create a markdown note. Errors if a note already exists at the path unless overwrite is set. Body replaces the entire note content: existing content will be lost unless you include it in body, so do not use this tool for surgical edits to large files. Properties are passed separately and merged with any existing properties when overwriting (new keys added, matching keys overwritten, keys set to null removed, unmentioned keys preserved); overwriting without properties keeps the existing property values. Example: vault_write_note({ path: "Projects/notes.md", body: "# Notes\\n\\nProject notes here.", properties: { tags: ["project"], type: "project" } }) Example: vault_write_note({ path: "Projects/notes.md", body: "Updated content.", overwrite: true }) @@ -385,12 +383,11 @@ Example: vault_write_note({ path: "Projects/notes.md", body: "Updated content.", When to use: Creating a new note. Set overwrite: true only when you intend to replace an existing note's body. Prefer vault_update_properties for property-only edits (no body round-trip).${whenToolEnabledText("vault_update_memory", `\nPrefer vault_update_memory for appending dated entries to ${config.memoryDir}/ memory files.`)} -Limitation: Writes the entire body. Do not use for surgical edits to large files — existing content will be lost unless you include it in the body parameter. - Errors: - "note already exists" — a note already lives at this path; set overwrite: true to replace it, or use ${whenToolEnabledText("vault_patch_note", "vault_patch_note / ")}vault_replace_in_note for partial edits - "path must end in …" — add the .md extension -- "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like ".obsidian/"; use a vault-relative path outside hidden folders +- "cannot write note …: that path is not a file" — a folder already has this name; choose another path +- "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; re-read the note and retry - "body contains a control character" — body includes a non-printable control byte; remove it before writing @@ -412,9 +409,7 @@ Returns: Confirmation message.`, properties: z .record(z.string().min(1), z.unknown()) .optional() - .describe( - "Optional properties to merge. New keys are added; existing keys with matching names are overwritten; a null value deletes that key; unmentioned keys are preserved from the existing file.", - ), + .describe("Optional properties to merge; a null value deletes that key."), overwrite: z .boolean() .optional() @@ -466,11 +461,11 @@ Operations: - replace: replace section body (heading preserved; requires heading; errors if the target has child headings unless include_children is set) - insert_before: insert content above the heading line (requires heading) -Heading-targeted ops keep the matched heading and write content verbatim — don't begin content with the target heading (it's rejected to avoid a duplicate). No separator is added around the content — end it with a newline to leave a blank line after the inserted block. +Heading-targeted ops keep the matched heading and write content verbatim. No separator is added around the content — end it with a newline to leave a blank line after the inserted block. -Limitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, that content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead. +Limitation: A no-heading prepend inserts at body line 0. If the note has content above its first heading and your content starts with a heading, the pre-existing content becomes the new section's body. The write still succeeds and the confirmation says so — use insert_before on the first heading to place a section above it instead. -Section boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF). Child headings are included in the parent section. Empty headings ("##" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead. +Section boundaries: a section spans from its heading to the next heading of the same or higher level (or EOF), so it includes its child headings. Empty headings ("##" with no text) act as boundaries but cannot be targeted — edit their content via vault_replace_in_note instead. Editing a leading callout: read it via vault_read_note(outline: true), then vault_replace_in_note the old block for the new one (a no-heading prepend would stack a second callout above it). @@ -480,9 +475,10 @@ Errors: - "heading not found" — no heading matches the text; error lists available headings - "ambiguous heading" — multiple headings match; use heading_level to disambiguate, or${whenToolEnabledText("vault_replace_in_note", " use vault_replace_in_note to")} target by text content when headings share the same level - "operation … requires a heading target" — replace and insert_before need a heading +- "heading cannot be empty" — heading is whitespace only; pass the heading's text - "content begins with the heading … which would duplicate it" — content's first line repeats the target heading; omit it (the matched heading is kept automatically) - "section … has N child headings …" — the target section contains child headings that replace would destroy; pass include_children: true to confirm, or target the child heading directly -- "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like ".obsidian/"; use a vault-relative path outside hidden folders +- "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; re-read the note and retry - "content contains a control character" — content includes a non-printable control byte; remove it before writing @@ -499,21 +495,19 @@ Returns: Confirmation message — "Applied to → ", ), operation: z .enum(["append", "prepend", "replace", "insert_before"]) - .describe( - "append | prepend | replace | insert_before. replace and insert_before require a heading; append and prepend work with or without one.", - ), + .describe("append | prepend | replace | insert_before."), content: z .string() .min(1) .describe( - "Markdown content to insert, written verbatim with no separator added — end it with a newline to leave a blank line after the inserted block. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically).", + "Markdown content to insert. Must not begin with the target heading text (it would duplicate the heading, which is kept automatically).", ), heading: z .string() .min(1) .optional() .describe( - "Target heading text (case-sensitive exact match). Required for replace and insert_before. Optional for append/prepend (omit for file-level operation).", + "Target heading text (case-sensitive exact match). Omit for a file-level append or prepend.", ), heading_level: z .number() @@ -584,16 +578,15 @@ When to use: Targeted text changes within a single location — fixing typos, up To delete a large multi-line block, prefer vault_delete_span (short anchors instead of full old_text).${whenToolEnabledText("vault_replace_span", " To replace a large block by anchors instead of reproducing the full old_text, use vault_replace_span.")}${whenToolEnabledText("vault_patch_note", ' To relocate content between headings, vault_patch_note to add at the target first, then remove from source (new_text="") — add-before-delete, so a failure duplicates the block instead of losing it.')} Parameters: -- old_text is matched in the body only — frontmatter properties are never searched. Include enough surrounding context to ensure uniqueness when the target text appears in multiple places. -- old_text + new_text together determine the operation: a non-empty new_text is an edit; an empty new_text ("") is a deletion. No regex — exact text only. -- replace_all_occurrences (default false) replaces only the first match — a safety default when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements. +- old_text: include enough surrounding context to ensure uniqueness when the target text appears in multiple places. No regex — exact text only. +- new_text: after a deletion (new_text=""), every run of consecutive blank lines in the note's body is collapsed to a single blank line. +- replace_all_occurrences: replacing only the first match is a safety default for when old_text appears in multiple places. Set true for deliberate bulk renames or term replacements. Errors: - "note not found" — path does not exist; check vault_list_notes for valid paths - "path must end in …" — add the .md extension - "text not found" — old_text does not appear in the note body; verify exact text with vault_read_note -- "oldText cannot be empty" — old_text must be at least one character -- "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like ".obsidian/"; use a vault-relative path outside hidden folders +- "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; re-read the note and retry - "new_text contains a control character" — new_text includes a non-printable control byte; remove it before writing @@ -673,14 +666,14 @@ Parameters: - start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and entire lines are removed (never cuts mid-line). Omit end_anchor for a single-line delete. - end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is deleted. - first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring. -- Blank-line runs left by the deletion are collapsed to a single blank line. +- After the deletion, every run of consecutive blank lines in the note's body is collapsed to a single blank line. Errors: - "note not found" — verify path with vault_list_notes - "path must end in …" — add the .md extension -- "anchor not found" — fragment not on any line; verify with vault_read_note +- "start anchor not found" / "end anchor not found" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note - "ambiguous start anchor …" / "ambiguous end anchor …" — the anchor matches multiple lines; use a longer fragment or set first_match: true -- "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like ".obsidian/"; use a vault-relative path outside hidden folders +- "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; re-read the note and retry Returns: Confirmation with lines removed and a truncated preview of the deleted text.`, @@ -702,7 +695,7 @@ Returns: Confirmation with lines removed and a truncated preview of the deleted .min(1) .optional() .describe( - "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to delete just the single line containing start_anchor.", + "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to delete just the single line containing start_anchor.", ), first_match: z .boolean() @@ -757,18 +750,18 @@ When to use: Replacing a block you have already read — a table row, callout, o ${whenToolEnabledText("vault_replace_in_note", "Prefer vault_replace_in_note for small in-place text changes (typos, renaming).")}${whenToolEnabledText("vault_delete_span", " Prefer vault_delete_span when removing without replacement.")} Parameters: -- start_anchor + end_anchor define a line range, not a text range — each anchor locates a full line, and the entire line from start to end is replaced (never cuts mid-line). Omit end_anchor for a single-line replace. +- start_anchor + end_anchor define a line range, not a text range (never cuts mid-line). - end_anchor is searched at or after the start line, so the span can never run backward. If both match the same line, only that one line is replaced. -- content replaces the entire matched span and must be non-empty. A trailing newline adds a blank line after the new block. -- first_match applies to both anchors independently — when an anchor matches multiple lines, takes the first instead of erroring. -- Blank-line runs left by the replacement are collapsed to a single blank line. +- content: a trailing newline adds a blank line after the new block. +- first_match applies to both anchors independently. +- After the replacement, every run of consecutive blank lines in the note's body — including runs within the replacement text — is collapsed to a single blank line. Errors: - "note not found" — verify path with vault_list_notes - "path must end in …" — add the .md extension -- "anchor not found" — fragment not on any line; verify with vault_read_note +- "start anchor not found" / "end anchor not found" — fragment not on any line (end_anchor: on no line at or after the start line); verify with vault_read_note - "ambiguous start anchor …" / "ambiguous end anchor …" — the anchor matches multiple lines; use a longer fragment or set first_match: true -- "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like ".obsidian/"; use a vault-relative path outside hidden folders +- "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; re-read the note and retry - "content contains a control character" — content includes a non-printable control byte; remove it before writing @@ -793,13 +786,13 @@ Returns: Confirmation message "Replaced lines with lines in " — .min(1) .optional() .describe( - "Short, unique substring that identifies the LAST line of the block, searched at or after the start_anchor line. The entire line is selected. Omit to replace just the single line containing start_anchor.", + "Short, unique substring that identifies the LAST line of the block. The entire line is selected. Omit to replace just the single line containing start_anchor.", ), content: z .string() .min(1) .describe( - `Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty${whenToolEnabledText("vault_delete_span", "; use vault_delete_span to delete without replacement")}.`, + "Replacement content (one or more lines) — replaces every line of the matched span. Must be non-empty.", ), first_match: z .boolean() @@ -855,17 +848,15 @@ When to use: Adding content at a precise location identified by a nearby line's ${whenToolEnabledText("vault_patch_note", "Prefer vault_patch_note for heading-targeted inserts (append/prepend to a section).")}${whenToolEnabledText("vault_replace_span", " Prefer vault_replace_span when replacing a block rather than inserting next to it.")} Parameters: -- anchor locates a full line — the content is inserted as whole lines before or after it (never splits a line). -- position: "before" inserts above the anchor line; "after" inserts below it. +- anchor locates a full line — the insert never splits a line. - content is inserted verbatim — blank lines inside it are kept, and a trailing newline adds a blank line after the inserted block. -- first_match: when the anchor matches multiple lines, takes the first instead of erroring. Errors: - "note not found" — verify path with vault_list_notes - "path must end in …" — add the .md extension - "anchor not found" — fragment not on any line; verify with vault_read_note - "ambiguous anchor …" — the anchor matches multiple lines; use a longer fragment or set first_match: true -- "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — the path starts at the filesystem root, escapes the vault, or targets a hidden (dot-prefixed) file or folder like ".obsidian/"; use a vault-relative path outside hidden folders +- "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; re-read the note and retry - "content contains a control character" — content includes a non-printable control byte; remove it before writing @@ -890,12 +881,7 @@ Returns: Confirmation message "Inserted lines anchor in lines anchor in