diff --git a/.env.example b/.env.example index c63738bef..8a01e7652 100644 --- a/.env.example +++ b/.env.example @@ -70,8 +70,8 @@ GHCR_USER= # default list) and turn on "Sync all other types" on the uploading device. # SYNC_FILE_TYPES= # Obsidian settings categories to sync into the server's .obsidian/ folder -# (default: the two the server reads — daily notes settings and community -# plugin settings such as the Tasks plugin's format; "none" disables). +# (default: daily notes settings and community plugin settings such as the +# Tasks plugin's format; "none" disables). # A category only syncs after your desktop pushes it: Settings → Sync → # "Vault configuration sync". Some community plugins keep API keys in their # settings — server tools never read .obsidian/, but synced settings do @@ -220,22 +220,27 @@ GHCR_USER= # DISABLED_TOOLS=vault_delete_note,vault_move_note # Memory folder name in your vault (default: "About Me") # MEMORY_DIR=About Me -# Daily notes folder and filename format (default: read from the -# vault's .obsidian/daily-notes.json, synced to the server via SYNC_CONFIGS -# above; falls back to "Daily Notes" and YYYY-MM-DD). Folder is any -# vault-relative path (Journal, Planner/Daily); format takes the same tokens -# as Obsidian's date format setting. +# Daily notes folder and filename format. Default: read from the vault's +# .obsidian/daily-notes.json, synced to the server via SYNC_CONFIGS above, or +# "Daily Notes" and YYYY-MM-DD when that file is missing or unreadable. An +# empty folder in that file means Obsidian's location for new notes, which +# syncs only when SYNC_CONFIGS includes app; without it, the server uses the +# vault root. Folder is any vault-relative path (Journal, Planner/Daily), or +# / for the vault root; format takes the same tokens as Obsidian's date format +# setting. # DAILY_NOTES_FOLDER=Journal # DAILY_NOTES_FORMAT=YYYY-MM-DD # Comma-separated folders protected from deletion and moves. Default: MEMORY_DIR plus # the daily notes folder, read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json -# (default "Daily Notes"). When set, replaces the whole default — include the -# daily notes folder in your list if needed. +# (default "Daily Notes"; none when that setting is the vault root or empty). +# When set, replaces the whole default — include the daily notes folder in +# your list if needed. # PROTECTED_PATHS=About Me,Daily Notes # Comma-separated folders excluded from orphan detection. # Default: the daily notes folder, Templates, and MEMORY_DIR. # DAILY_NOTES_FOLDER wins; otherwise .obsidian/daily-notes.json is reread -# on each query, falling back to "Daily Notes". Root-level daily notes remain eligible. +# on each query, falling back to "Daily Notes". A vault-root or empty folder +# setting adds no daily folder, so those daily notes remain eligible. # When set, replaces the whole default — include every folder to keep excluded. # Set ORPHAN_EXCLUDE_FOLDERS=, to exclude nothing; an empty value uses the defaults. # Apply env changes by redeploying; synced Obsidian settings apply on the next query. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d84151d5e..1e3a16c6f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -186,7 +186,7 @@ Unlike a read that parses the block, such as `vault_read_note` with `properties_ `vault_delete_note` and `vault_move_note` refuse paths under protected folders as a server-side guardrail: -- **Default set:** the memory dir plus the daily notes folder, resolved on every delete and move as [Daily notes](#property-discovery--daily-notes) describes +- **Default set:** the memory dir plus the daily notes folder, resolved on every delete and move as [Daily notes](#property-discovery--daily-notes) describes; a vault-root or blank folder setting adds no folder - **Unreadable `daily-notes.json`:** a file that exists but cannot be read or parsed refuses the delete or move, because the folder to protect is unknown; setting `DAILY_NOTES_FOLDER` skips the file - **Override:** `PROTECTED_PATHS` replaces the default set entirely, and `daily-notes.json` is not read @@ -246,8 +246,11 @@ Every tag input (`tag` on `vault_search_by_tag` and `vault_list_tasks`, the `tag **Daily notes:** `vault_get_daily_note` resolves the vault's folder and date format, each independently: `DAILY_NOTES_FOLDER`/`DAILY_NOTES_FORMAT` env setting → `.obsidian/daily-notes.json` → fallback (`Daily Notes/YYYY-MM-DD.md`). - **Read on every call:** a folder or format change in Obsidian applies to the next call, and a config file that arrives after boot needs no restart +- **File folder normalized like Obsidian:** in `daily-notes.json`, runs of `/` or `\` become one `/` and leading and trailing slashes are dropped, so Obsidian's `"/"` is the vault root; no-break spaces become plain spaces and the name is NFC-normalized. `DAILY_NOTES_FOLDER` takes `/` for the vault root and is otherwise validated as a vault folder name +- **Blank folder in the file:** Obsidian's new-note location (`newFileLocation: "folder"` plus `newFileFolderPath` in `.obsidian/app.json`), otherwise the vault root - **Missing file:** takes the fallbacks - **Unparseable file:** takes the fallbacks and logs a `warn` +- **Shared folder:** a vault-root or blank folder setting puts daily notes beside other notes, so it adds no folder to the delete and move guard, the orphan exclusion, or forward-reference handling (broken links into the daily folder, flagged `daily_note_forward_ref` by `vault_get_outgoing_links` and left out of `vault-orientation`'s broken-link count) ### Memory @@ -311,10 +314,10 @@ Link queries use a `links` table populated during indexing: 3. Basename (shortest-path-first for ambiguous basenames) - **Non-markdown files:** Targets that don't resolve to a note are checked against a `non_md_files` table (populated during rebuild, maintained by the file watcher). Both wikilinks and markdown-style links to `.canvas`, `.base`, images, PDFs, and other non-markdown files resolve as `kind: "file"` instead of being counted as broken. - **Outgoing links:** `vault_get_outgoing_links` returns a `kind` discriminator (`"note"` or `"file"`) plus each target's byte size (`bytes` — from the notes table for notes, from `non_md_files` for files), so clients can route notes to `vault_read_note` and files to `vault_read_file` with size awareness. -- **Orphans:** `vault_find_orphans` and `vault-orientation` resolve default exclusions on each invocation: the daily notes folder (`DAILY_NOTES_FOLDER` → `.obsidian/daily-notes.json` → `Daily Notes`), `Templates`, and the memory dir. +- **Orphans:** `vault_find_orphans` and `vault-orientation` resolve default exclusions on each invocation: the daily notes folder (resolved as [Daily notes](#property-discovery--daily-notes) describes), `Templates`, and the memory dir. - `ORPHAN_EXCLUDE_FOLDERS` replaces that list; a tool call's `exclude_folders` replaces it for that call. - An unreadable or malformed config logs a warning and uses `Daily Notes`; a missing file uses that fallback without a warning. - - An empty Obsidian folder setting uses the `Daily Notes` fallback. A whitespace-only folder adds no daily-folder exclusion. Neither excludes the whole vault, so root-level daily notes remain eligible. + - A vault-root, blank, or whitespace-only folder setting adds no daily-folder exclusion, so those daily notes remain eligible. ### Files @@ -1254,7 +1257,8 @@ Docker hardening, and durability seatbelts above. `join`, bypassing `resolveSafePath`'s hidden-path guard). - **Protected paths**: `PROTECTED_PATHS` (default: `MEMORY_DIR` plus the daily notes folder, from `DAILY_NOTES_FOLDER`, then - `.obsidian/daily-notes.json`, then `Daily Notes`) blocks deleting + `.obsidian/daily-notes.json`, then `Daily Notes`; none for a vault-root or + blank folder setting) blocks deleting notes in, moving notes out of, and moving notes into configured folders. The check (`isProtectedPath()`, shared by delete and move) runs on the canonical vault-relative path with a case-folded diff --git a/DEPLOY.md b/DEPLOY.md index 571baed44..b6596f870 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -364,13 +364,13 @@ To find your stage: `cat .sst/stage` (after one-time setup). | `READONLY_MODE` | Optional. Set `true` to hide every tool that changes the vault and skip memory folder auto-creation — read and search only. Default: `false`. | | `DISABLED_TOOLS` | Optional. Hide individual tools by name, comma-separated. Names match the Tool column in the [README tools table](./README.md#tools). Subtractive only; an unknown tool name stops the server at startup. Default: none hidden. | | `MEMORY_DIR` | Optional. Memory folder name in the vault (default: `About Me`). See the [Configuration](./README.md#configuration) section. | -| `PROTECTED_PATHS` | Optional. Comma-separated folders protected from deletion and moves (default: `MEMORY_DIR` plus the daily notes folder, read from `DAILY_NOTES_FOLDER` or `.obsidian/daily-notes.json`, default `Daily Notes`). Overrides the default entirely when set. | -| `ORPHAN_EXCLUDE_FOLDERS` | Optional. Comma-separated folders excluded from orphan detection (default: the daily notes folder, `Templates`, and `MEMORY_DIR`). The `DAILY_NOTES_FOLDER` variable wins; otherwise synced `.obsidian/daily-notes.json` is reread per query (fallback `Daily Notes`). Overrides the default entirely when set. Use `,` to exclude nothing; an empty value uses the defaults. Apply variable changes with Force Deploy; synced Obsidian changes need no redeploy. See [Daily notes](./README.md#daily-notes) for root-level notes. | +| `PROTECTED_PATHS` | Optional. Comma-separated folders protected from deletion and moves (default: `MEMORY_DIR` plus the daily notes folder, read from `DAILY_NOTES_FOLDER` or `.obsidian/daily-notes.json`, default `Daily Notes`; none when that setting is the vault root or empty). Overrides the default entirely when set. | +| `ORPHAN_EXCLUDE_FOLDERS` | Optional. Comma-separated folders excluded from orphan detection (default: the daily notes folder, `Templates`, and `MEMORY_DIR`). The `DAILY_NOTES_FOLDER` variable wins; otherwise synced `.obsidian/daily-notes.json` is reread per query (fallback `Daily Notes`). Overrides the default entirely when set. Use `,` to exclude nothing; an empty value uses the defaults. Apply variable changes with Force Deploy; synced Obsidian changes need no redeploy. See [Daily notes](./README.md#daily-notes) for root and empty settings. | | `SERVICE_DOCUMENTATION_URL` | Optional. URL in OAuth discovery metadata (default: `https://github.com/aliasunder/vault-cortex`). Set to your fork's URL. | | `SYNC_CONFIGS` | Optional. Obsidian settings categories synced to the server, comma-separated (default: `core-plugin-data,community-plugin-data` — daily-notes settings and the Tasks plugin's format). Set `none` to disable. See [Daily notes](./README.md#daily-notes). | | `SYNC_EXCLUDED_FOLDERS` | Optional. Folders to leave out of Obsidian Sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Unset syncs everything. | | `SYNC_FILE_TYPES` | Optional. Attachment types Obsidian Sync delivers, comma-separated. **Valid values:** `image`, `audio`, `video`, `pdf`, `unsupported`. **Unset (default):** `image,audio,video,pdf`. The text files Vault Cortex reads (CSV, JSON, TXT, XML, LOG, YAML) fall under `unsupported`, Obsidian Sync's name for its **Sync all other types** category; SVG syncs as `image`, and notes, canvases, and bases always sync. To read those text files, set it to `image,audio,video,pdf,unsupported` (the value replaces the default list, so keep the four defaults) and turn on **Sync all other types** in Obsidian's Sync settings on the device that has them. An invalid value is skipped with a warning in the logs, and the default types sync instead. | -| `DAILY_NOTES_FOLDER` | Optional. Sets the daily notes folder (default: read from the vault's `.obsidian/daily-notes.json`, falling back to `Daily Notes`). See [Daily notes](./README.md#daily-notes). | +| `DAILY_NOTES_FOLDER` | Optional. Sets the daily notes folder: any vault-relative path, or `/` for the vault root (default: read from the vault's `.obsidian/daily-notes.json`; `Daily Notes` when that file is missing or unreadable). See [Daily notes](./README.md#daily-notes). | | `DAILY_NOTES_FORMAT` | Optional. Sets the daily note filename format, in the same tokens as Obsidian's daily note date format setting (default: read from the vault's config, falling back to `YYYY-MM-DD`). | | `TZ` | Optional. Container timezone (default: `UTC`). Affects `vault_update_memory` date stamps and `vault_get_daily_note` date resolution. Set to your IANA timezone (e.g. `America/New_York`). | | `LOG_LEVEL` | Optional. Logging verbosity: `debug`, `info`, `warn`, `error`. Default: `info`. | diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 7013cd4f5..07edf1bfb 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -151,7 +151,7 @@ Vault Cortex indexes every [property](https://help.obsidian.md/Editing+and+forma All settings are environment variables with sensible defaults. Some defaults derive from other settings — the Default column shows each derivation, and a value you set replaces the whole derived default. Remote deployments also forward Obsidian Sync's own settings — `DEVICE_NAME`, `SYNC_MODE`, `CONFLICT_STRATEGY`, `SYNC_CONFIGS`, `SYNC_EXCLUDED_FOLDERS`, `SYNC_FILE_TYPES` — documented in the [remote guide's configuration table](https://github.com/aliasunder/vault-cortex/tree/main/deploy/remote/README.md#configuration). | Variable | Required? | Default | Description | -| --------------------------- | ----------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| --------------------------- | ----------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MCP_AUTH_TOKEN` | Yes | — | Bearer token for authentication (also the JWT signing key) | | `VAULT_PATH` | Local only | — | Host path to your vault (bind mount source; remote uses a named volume). Must not contain `*`, `?`, or `[` — rejected at startup. | | `PUBLIC_URL` | Remote only | — | Public URL for OAuth discovery metadata. Filled in automatically on Render and Railway (from `RENDER_EXTERNAL_URL` or `RAILWAY_PUBLIC_DOMAIN`) when left unset | @@ -166,10 +166,10 @@ All settings are environment variables with sensible defaults. Some defaults der | `READONLY_MODE` | — | `false` | Set `true` to hide every tool that changes the vault and skip memory folder auto-creation — connected clients can read and search but never edit. | | `DISABLED_TOOLS` | — | — | Hide individual tools by name, comma-separated (e.g. `vault_delete_note,vault_move_note`). Names match the Tool column in the [tools table](https://github.com/aliasunder/vault-cortex#tools). Subtractive only — it cannot re-enable a tool another setting hides. An unknown tool name stops the server at startup, so typos surface immediately. | | `MEMORY_DIR` | — | `About Me` | Vault folder for structured memory files | -| `PROTECTED_PATHS` | — | `MEMORY_DIR`, daily notes folder | Folders that `vault_delete_note` and `vault_move_note` refuse to touch. The default daily notes folder is read from `DAILY_NOTES_FOLDER` or `.obsidian/daily-notes.json` (default `Daily Notes`). Overrides the default entirely when set. | -| `ORPHAN_EXCLUDE_FOLDERS` | — | daily notes folder, `Templates`, `MEMORY_DIR` | Folders excluded from orphan detection. `DAILY_NOTES_FOLDER` wins; otherwise `.obsidian/daily-notes.json` is reread per query (fallback `Daily Notes`). Set a list to replace all defaults, or `ORPHAN_EXCLUDE_FOLDERS=,` to exclude nothing. An empty value uses the defaults. Root-level daily notes remain eligible. See [Daily notes](https://github.com/aliasunder/vault-cortex#daily-notes). | -| `DAILY_NOTES_FOLDER` | — | from vault config | Sets the folder your daily notes live in. When unset, read from the vault's `.obsidian/daily-notes.json`, falling back to `Daily Notes`. See [Daily notes](https://github.com/aliasunder/vault-cortex#daily-notes). | -| `DAILY_NOTES_FORMAT` | — | from vault config | Sets the daily note filename format — same tokens as Obsidian's daily note date format setting. When unset, read from the vault's `.obsidian/daily-notes.json`, falling back to `YYYY-MM-DD`. See [Daily notes](https://github.com/aliasunder/vault-cortex#daily-notes). | +| `PROTECTED_PATHS` | — | `MEMORY_DIR`, daily notes folder | Folders that `vault_delete_note` and `vault_move_note` refuse to touch. The default daily notes folder is read from `DAILY_NOTES_FOLDER` or `.obsidian/daily-notes.json` (default `Daily Notes`); see [Daily notes](https://github.com/aliasunder/vault-cortex#daily-notes) for root and empty settings. Overrides the default entirely when set. | +| `ORPHAN_EXCLUDE_FOLDERS` | — | daily notes folder, `Templates`, `MEMORY_DIR` | Folders excluded from orphan detection. The daily notes folder is read from `DAILY_NOTES_FOLDER` or `.obsidian/daily-notes.json` (default `Daily Notes`); see [Daily notes](https://github.com/aliasunder/vault-cortex#daily-notes) for root and empty settings. Set a list to replace all defaults, or `ORPHAN_EXCLUDE_FOLDERS=,` to exclude nothing. An empty value uses the defaults. | +| `DAILY_NOTES_FOLDER` | — | from `daily-notes.json` | Sets the folder your daily notes live in. When unset, read from `.obsidian/daily-notes.json`; `Daily Notes` when that file is missing or unreadable. See [Daily notes](https://github.com/aliasunder/vault-cortex#daily-notes). | +| `DAILY_NOTES_FORMAT` | — | from `daily-notes.json` | Sets the daily note filename format — same tokens as Obsidian's daily note date format setting. When unset, read from `.obsidian/daily-notes.json`, falling back to `YYYY-MM-DD`. See [Daily notes](https://github.com/aliasunder/vault-cortex#daily-notes). | | `TZ` | — | `UTC` | IANA timezone for timestamps and daily note resolution | | `SERVICE_DOCUMENTATION_URL` | — | GitHub repo URL | URL returned in OAuth discovery metadata | | `LOG_LEVEL` | — | `info` | Logging verbosity: `debug`, `info`, `warn`, `error` | diff --git a/README.md b/README.md index 36bd47a6e..efc2ff60a 100644 --- a/README.md +++ b/README.md @@ -371,38 +371,38 @@ These are conventions, not requirements — Vault Cortex works with any property All settings are environment variables with sensible defaults. Some defaults derive from other settings — the Default column shows each derivation, and a value you set replaces the whole derived default. Remote deployments also forward Obsidian Sync's own settings — `DEVICE_NAME`, `SYNC_MODE`, `CONFLICT_STRATEGY`, `SYNC_CONFIGS`, `SYNC_EXCLUDED_FOLDERS`, `SYNC_FILE_TYPES` — documented in the [remote guide's configuration table](./deploy/remote/README.md#configuration). -| Variable | Required? | Default | Description | -| --------------------------- | ----------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MCP_AUTH_TOKEN` | Yes | — | Bearer token for authentication (also the JWT signing key) | -| `VAULT_PATH` | Local only | — | Host path to your vault (bind mount source; remote uses a named volume). Must not contain `*`, `?`, or `[` — rejected at startup. | -| `PUBLIC_URL` | Remote only | — | Public URL for OAuth discovery metadata. Filled in automatically on Render and Railway (from `RENDER_EXTERNAL_URL` or `RAILWAY_PUBLIC_DOMAIN`) when left unset | -| `OBSIDIAN_AUTH_TOKEN` | — | — | Obsidian Sync auth token. Leave empty to sign in through the `/setup` page after deploy; or the CLI's [`get-sync-token`](./cli/README.md#get-sync-token) captures it for you | -| `VAULT_NAME` | Remote only | — | Exact name of your Obsidian vault (case-sensitive) | -| `VAULT_PASSWORD` | Remote only | — | End-to-end encryption password, if your vault has one. Leave empty otherwise. | -| `STORAGE_ROOT` | — | — | One directory for everything that must persist — the vault, the search index, and Obsidian Sync state — for container hosting platforms that allow a single persistent volume (Railway, Render). Mount the volume there and set this to the same path. Must not contain `*`, `?`, or `[` — rejected at startup. | -| `EMBEDDING_ENABLED` | — | `true` | Set `false` to disable the embedding pipeline — skips model download, vector tables, embedding passes, and hybrid search. Search falls back to FTS5 keyword matching. | -| `RERANK_MODE` | — | `blended` | Cross-encoder reranking mode: `blended` applies position-aware score blending after RRF fusion (~200ms added latency), `none` skips reranking. Only takes effect when `EMBEDDING_ENABLED` is true. | -| `MEMORY_ENABLED` | — | `true` | Set `false` to fully disable the memory layer — hides memory tools, skips bootstrap, omits memory from server metadata. `MEMORY_DIR` still supplies the defaults for `PROTECTED_PATHS` and `ORPHAN_EXCLUDE_FOLDERS` when `false`. | -| `FILE_TOOLS_ENABLED` | — | `true` | Set `false` to hide file tools (`vault_read_file`, `vault_list_files`) — useful for remote deployments where Obsidian Sync has attachment syncing disabled. | -| `READONLY_MODE` | — | `false` | Set `true` to hide every tool that changes the vault and skip memory folder auto-creation — connected clients can read and search but never edit. | -| `DISABLED_TOOLS` | — | — | Hide individual tools by name, comma-separated (e.g. `vault_delete_note,vault_move_note`). Names match the Tool column in the [tools table](#tools). Subtractive only — it cannot re-enable a tool another setting hides. An unknown tool name stops the server at startup, so typos surface immediately. | -| `MEMORY_DIR` | — | `About Me` | Vault folder for structured memory files | -| `PROTECTED_PATHS` | — | `MEMORY_DIR`, daily notes folder | Folders that `vault_delete_note` and `vault_move_note` refuse to touch. The default daily notes folder is read from `DAILY_NOTES_FOLDER` or `.obsidian/daily-notes.json` (default `Daily Notes`). Overrides the default entirely when set. | -| `ORPHAN_EXCLUDE_FOLDERS` | — | daily notes folder, `Templates`, `MEMORY_DIR` | Folders excluded from orphan detection. `DAILY_NOTES_FOLDER` wins; otherwise `.obsidian/daily-notes.json` is reread per query (fallback `Daily Notes`). Set a list to replace all defaults, or `ORPHAN_EXCLUDE_FOLDERS=,` to exclude nothing. An empty value uses the defaults. Root-level daily notes remain eligible. See [Daily notes](#daily-notes). | -| `DAILY_NOTES_FOLDER` | — | from vault config | Sets the folder your daily notes live in. When unset, read from the vault's `.obsidian/daily-notes.json`, falling back to `Daily Notes`. See [Daily notes](#daily-notes). | -| `DAILY_NOTES_FORMAT` | — | from vault config | Sets the daily note filename format — same tokens as Obsidian's daily note date format setting. When unset, read from the vault's `.obsidian/daily-notes.json`, falling back to `YYYY-MM-DD`. See [Daily notes](#daily-notes). | -| `TZ` | — | `UTC` | IANA timezone for timestamps and daily note resolution | -| `SERVICE_DOCUMENTATION_URL` | — | GitHub repo URL | URL returned in OAuth discovery metadata | -| `LOG_LEVEL` | — | `info` | Logging verbosity: `debug`, `info`, `warn`, `error` | -| `LOG_DIR` | — | `/data/logs` (remote), `$STORAGE_ROOT/data/logs` (single-volume), `none` (local) | Directory for log files that survive container re-creation. The container's own log (what `docker logs` shows) is always written, but Docker discards it whenever the container is recreated — on image updates or config changes. Date-stamped files under `LOG_DIR` live on the data volume and survive. `none` keeps only the container log. | -| `LOG_RETENTION_DAYS` | — | `90` | Days to keep log files before automatic cleanup on startup; only applies when `LOG_DIR` is a path | -| `WINDOWS_MODE` | — | `false` | On Windows? Set `true`. Switches the file watcher to polling and note moves to rename-based writes so a vault on a `C:` drive works through Docker Desktop. Safe to leave on for any Windows setup; unneeded on macOS/Linux/WSL2. | -| `MAX_FILE_BYTES` | — | `52428800` (50 MiB) | Maximum file size `vault_read_file` will read (in bytes). Files exceeding this are rejected before reading. Raise for vaults with very large individual files. | -| `MAX_IMAGE_OUTPUT_BYTES` | — | `49152` (48 KiB) | Byte budget for images delivered by `vault_read_file`, in binary bytes before base64 encoding. Images exceeding this are downscaled and recompressed to fit. Sized for the tightest mainstream MCP client cap; raise for clients that accept larger responses. | -| `MAX_PDF_RENDER_PAGES` | — | `5` | Maximum PDF pages to render as images when `raw: true` is set on `vault_read_file`. The per-page byte budget is `MAX_IMAGE_OUTPUT_BYTES` divided evenly across the rendered pages — fewer pages means higher quality each. | -| `TRASH_RETENTION_DAYS` | Local only | `30` | Days a note deleted under Obsidian's default "Move to system trash" setting stays in `.trash/` before the server cleans it up. Set `none` to keep those notes forever. Only notes the server itself moved there are cleaned up. With Obsidian Sync, deletes are permanent on the server and recoverable from Sync's version history. | -| `TRUST_PROXY_HOPS` | — | `0` | Number of trusted reverse-proxy hops used to derive the client IP from `X-Forwarded-For` (OAuth rate limiting, request logs). Set `1` when exactly one proxy you control sits in front of the server (Caddy, nginx, Cloudflare Tunnel, API Gateway). With `0`, injected forwarding headers are ignored. | -| `TRUST_FORWARDED_HOPS` | — | `0` | How many trailing `for=` entries in the [RFC 7239](https://www.rfc-editor.org/rfc/rfc7239) `Forwarded` header belong to proxies you control. `0` ignores the header; `1` when the proxy in front writes it (e.g. AWS API Gateway); `2` when a CDN fronts that proxy and is the only way to reach it. | +| Variable | Required? | Default | Description | +| --------------------------- | ----------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_AUTH_TOKEN` | Yes | — | Bearer token for authentication (also the JWT signing key) | +| `VAULT_PATH` | Local only | — | Host path to your vault (bind mount source; remote uses a named volume). Must not contain `*`, `?`, or `[` — rejected at startup. | +| `PUBLIC_URL` | Remote only | — | Public URL for OAuth discovery metadata. Filled in automatically on Render and Railway (from `RENDER_EXTERNAL_URL` or `RAILWAY_PUBLIC_DOMAIN`) when left unset | +| `OBSIDIAN_AUTH_TOKEN` | — | — | Obsidian Sync auth token. Leave empty to sign in through the `/setup` page after deploy; or the CLI's [`get-sync-token`](./cli/README.md#get-sync-token) captures it for you | +| `VAULT_NAME` | Remote only | — | Exact name of your Obsidian vault (case-sensitive) | +| `VAULT_PASSWORD` | Remote only | — | End-to-end encryption password, if your vault has one. Leave empty otherwise. | +| `STORAGE_ROOT` | — | — | One directory for everything that must persist — the vault, the search index, and Obsidian Sync state — for container hosting platforms that allow a single persistent volume (Railway, Render). Mount the volume there and set this to the same path. Must not contain `*`, `?`, or `[` — rejected at startup. | +| `EMBEDDING_ENABLED` | — | `true` | Set `false` to disable the embedding pipeline — skips model download, vector tables, embedding passes, and hybrid search. Search falls back to FTS5 keyword matching. | +| `RERANK_MODE` | — | `blended` | Cross-encoder reranking mode: `blended` applies position-aware score blending after RRF fusion (~200ms added latency), `none` skips reranking. Only takes effect when `EMBEDDING_ENABLED` is true. | +| `MEMORY_ENABLED` | — | `true` | Set `false` to fully disable the memory layer — hides memory tools, skips bootstrap, omits memory from server metadata. `MEMORY_DIR` still supplies the defaults for `PROTECTED_PATHS` and `ORPHAN_EXCLUDE_FOLDERS` when `false`. | +| `FILE_TOOLS_ENABLED` | — | `true` | Set `false` to hide file tools (`vault_read_file`, `vault_list_files`) — useful for remote deployments where Obsidian Sync has attachment syncing disabled. | +| `READONLY_MODE` | — | `false` | Set `true` to hide every tool that changes the vault and skip memory folder auto-creation — connected clients can read and search but never edit. | +| `DISABLED_TOOLS` | — | — | Hide individual tools by name, comma-separated (e.g. `vault_delete_note,vault_move_note`). Names match the Tool column in the [tools table](#tools). Subtractive only — it cannot re-enable a tool another setting hides. An unknown tool name stops the server at startup, so typos surface immediately. | +| `MEMORY_DIR` | — | `About Me` | Vault folder for structured memory files | +| `PROTECTED_PATHS` | — | `MEMORY_DIR`, daily notes folder | Folders that `vault_delete_note` and `vault_move_note` refuse to touch. The default daily notes folder is read from `DAILY_NOTES_FOLDER` or `.obsidian/daily-notes.json` (default `Daily Notes`); see [Daily notes](#daily-notes) for root and empty settings. Overrides the default entirely when set. | +| `ORPHAN_EXCLUDE_FOLDERS` | — | daily notes folder, `Templates`, `MEMORY_DIR` | Folders excluded from orphan detection. The daily notes folder is read from `DAILY_NOTES_FOLDER` or `.obsidian/daily-notes.json` (default `Daily Notes`); see [Daily notes](#daily-notes) for root and empty settings. Set a list to replace all defaults, or `ORPHAN_EXCLUDE_FOLDERS=,` to exclude nothing. An empty value uses the defaults. | +| `DAILY_NOTES_FOLDER` | — | from `daily-notes.json` | Sets the folder your daily notes live in. When unset, read from `.obsidian/daily-notes.json`; `Daily Notes` when that file is missing or unreadable. See [Daily notes](#daily-notes). | +| `DAILY_NOTES_FORMAT` | — | from `daily-notes.json` | Sets the daily note filename format — same tokens as Obsidian's daily note date format setting. When unset, read from `.obsidian/daily-notes.json`, falling back to `YYYY-MM-DD`. See [Daily notes](#daily-notes). | +| `TZ` | — | `UTC` | IANA timezone for timestamps and daily note resolution | +| `SERVICE_DOCUMENTATION_URL` | — | GitHub repo URL | URL returned in OAuth discovery metadata | +| `LOG_LEVEL` | — | `info` | Logging verbosity: `debug`, `info`, `warn`, `error` | +| `LOG_DIR` | — | `/data/logs` (remote), `$STORAGE_ROOT/data/logs` (single-volume), `none` (local) | Directory for log files that survive container re-creation. The container's own log (what `docker logs` shows) is always written, but Docker discards it whenever the container is recreated — on image updates or config changes. Date-stamped files under `LOG_DIR` live on the data volume and survive. `none` keeps only the container log. | +| `LOG_RETENTION_DAYS` | — | `90` | Days to keep log files before automatic cleanup on startup; only applies when `LOG_DIR` is a path | +| `WINDOWS_MODE` | — | `false` | On Windows? Set `true`. Switches the file watcher to polling and note moves to rename-based writes so a vault on a `C:` drive works through Docker Desktop. Safe to leave on for any Windows setup; unneeded on macOS/Linux/WSL2. | +| `MAX_FILE_BYTES` | — | `52428800` (50 MiB) | Maximum file size `vault_read_file` will read (in bytes). Files exceeding this are rejected before reading. Raise for vaults with very large individual files. | +| `MAX_IMAGE_OUTPUT_BYTES` | — | `49152` (48 KiB) | Byte budget for images delivered by `vault_read_file`, in binary bytes before base64 encoding. Images exceeding this are downscaled and recompressed to fit. Sized for the tightest mainstream MCP client cap; raise for clients that accept larger responses. | +| `MAX_PDF_RENDER_PAGES` | — | `5` | Maximum PDF pages to render as images when `raw: true` is set on `vault_read_file`. The per-page byte budget is `MAX_IMAGE_OUTPUT_BYTES` divided evenly across the rendered pages — fewer pages means higher quality each. | +| `TRASH_RETENTION_DAYS` | Local only | `30` | Days a note deleted under Obsidian's default "Move to system trash" setting stays in `.trash/` before the server cleans it up. Set `none` to keep those notes forever. Only notes the server itself moved there are cleaned up. With Obsidian Sync, deletes are permanent on the server and recoverable from Sync's version history. | +| `TRUST_PROXY_HOPS` | — | `0` | Number of trusted reverse-proxy hops used to derive the client IP from `X-Forwarded-For` (OAuth rate limiting, request logs). Set `1` when exactly one proxy you control sits in front of the server (Caddy, nginx, Cloudflare Tunnel, API Gateway). With `0`, injected forwarding headers are ignored. | +| `TRUST_FORWARDED_HOPS` | — | `0` | How many trailing `for=` entries in the [RFC 7239](https://www.rfc-editor.org/rfc/rfc7239) `Forwarded` header belong to proxies you control. `0` ignores the header; `1` when the proxy in front writes it (e.g. AWS API Gateway); `2` when a CDN fronts that proxy and is the only way to reach it. | See [`templates/memory/`](./templates/memory/) for memory file examples and the dated-entry design philosophy. @@ -411,17 +411,19 @@ See [`templates/memory/`](./templates/memory/) for memory file examples and the `vault_get_daily_note` and the daily-review prompt find your daily notes using the folder and filename date format configured in Obsidian, read from your vault's `.obsidian/daily-notes.json`: - **Local mode** reads the file straight from your bind-mounted vault — nothing to set up. -- **Remote mode** receives it through Obsidian Sync's vault configuration syncing. The server pulls it by default (the `SYNC_CONFIGS` setting in `.env`), but you'll likely need to enable the push side: Obsidian Settings → Sync → **Vault configuration sync**, per device. Details: the [remote guide's Daily notes section](./deploy/remote/README.md#daily-notes). +- **Remote mode** receives it through Obsidian Sync's vault configuration syncing. The server pulls it by default (the `SYNC_CONFIGS` setting in `.env`), but your devices must also send it: turn on Obsidian Settings → Sync → **Vault configuration sync** on each device. Details: the [remote guide's Daily notes section](./deploy/remote/README.md#daily-notes). -When the file isn't available — or if you use the Periodic Notes plugin, whose settings it doesn't reflect — set the values yourself: +When the file isn't available — or if you use the Periodic Notes plugin, whose settings `daily-notes.json` doesn't hold — set the values yourself: -- `DAILY_NOTES_FOLDER` — any vault-relative path: `Journal`, `Planner/Daily` +- `DAILY_NOTES_FOLDER` — any vault-relative path: `Journal`, `Planner/Daily`, or `/` for the vault root - `DAILY_NOTES_FORMAT` — same tokens as Obsidian's date format setting: `YYYY-MM-DD-dddd`, `YYYY/MM/DD`, `MMM D, YYYY`, … -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`. +You can set one or both — a set value always wins over the config file. A value you leave unset comes from `daily-notes.json`; when that file is missing, the server uses `Daily Notes` and `YYYY-MM-DD`. -- Changes in Obsidian apply on the next call when `.obsidian/daily-notes.json` is available in the server's vault. Apply environment changes with the CLI's `restart` command, recreate the Compose container, or redeploy on your hosting platform. -- An empty Obsidian folder setting uses the server's `Daily Notes` fallback. Daily notes stored at the vault root remain eligible for orphan results; the server never excludes the whole vault automatically. +- Changes in Obsidian apply on the next call when `.obsidian/daily-notes.json` is available in the server's vault. +- Apply environment changes with the CLI's `restart` command, recreate the Compose container, or redeploy on your hosting platform. +- If you leave the folder empty in Obsidian's Daily notes options, the server follows Obsidian's **Default location for new notes** setting (Settings → Files and links), read from `.obsidian/app.json`. **Same folder as current file** counts as the vault root, because Obsidian creates a daily note without reference to an open note and so has no folder to follow. When `app.json` isn't in the server's vault, as in remote mode with the default `SYNC_CONFIGS`, the server uses the vault root. +- If your daily notes live in the vault root, or in the new-note folder because the folder setting is empty, delete and move don't protect them and orphan results can include them. Protecting or excluding that folder would also cover every other note in it. > **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/cli/src/__tests__/optional-settings.test.ts b/cli/src/__tests__/optional-settings.test.ts index 7f53ab747..37ee0a0d2 100644 --- a/cli/src/__tests__/optional-settings.test.ts +++ b/cli/src/__tests__/optional-settings.test.ts @@ -611,6 +611,15 @@ describe("DAILY_NOTES_FOLDER validate callback", () => { ]) expect(overrides).toEqual({ DAILY_NOTES_FOLDER: "Journal" }) }) + + it("accepts / as the vault root", async () => { + const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "/"]) + + const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + + expect(scripted.errors).toEqual([]) + expect(overrides).toEqual({ DAILY_NOTES_FOLDER: "/" }) + }) }) describe("DAILY_NOTES_FORMAT validate callback", () => { diff --git a/cli/src/env.ts b/cli/src/env.ts index c07535ad3..7cab4035c 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -94,23 +94,27 @@ READONLY_MODE=false # Memory folder name in your vault (default: About Me). MEMORY_DIR=About Me -# Daily notes folder and filename format (default: read from the -# vault's .obsidian/daily-notes.json, falling back to "Daily Notes" and -# YYYY-MM-DD). Folder is any vault-relative path (Journal, Planner/Daily); -# format takes the same tokens as Obsidian's date format setting. +# Daily notes folder and filename format. Default: read from the vault's +# .obsidian/daily-notes.json, or "Daily Notes" and YYYY-MM-DD when that file +# is missing or unreadable. An empty folder in that file means Obsidian's +# location for new notes. Folder is any vault-relative path (Journal, +# Planner/Daily), or / for the vault root; format takes the same tokens as +# Obsidian's date format setting. # DAILY_NOTES_FOLDER=Journal # DAILY_NOTES_FORMAT=YYYY-MM-DD # Comma-separated folders protected from deletion and moves. Default: MEMORY_DIR plus # the daily notes folder, read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json -# (default "Daily Notes"). When set, replaces the whole default — include the -# daily notes folder in your list if needed. +# (default "Daily Notes"; none when that setting is the vault root or empty). +# When set, replaces the whole default — include the daily notes folder in +# your list if needed. # PROTECTED_PATHS=About Me,Daily Notes # Comma-separated folders excluded from orphan detection. # Default: the daily notes folder, Templates, and MEMORY_DIR. # DAILY_NOTES_FOLDER wins; otherwise .obsidian/daily-notes.json is reread -# on each query, falling back to "Daily Notes". Root-level daily notes remain eligible. +# on each query, falling back to "Daily Notes". A vault-root or empty folder +# setting adds no daily folder, so those daily notes remain eligible. # When set, replaces the whole default — include every folder to keep excluded. # Set ORPHAN_EXCLUDE_FOLDERS=, to exclude nothing; an empty value uses the defaults. # To apply environment changes, run the CLI's restart command @@ -242,24 +246,29 @@ READONLY_MODE=false # Memory folder name in your vault (default: About Me). MEMORY_DIR=About Me -# Daily notes folder and filename format (default: read from the -# vault's .obsidian/daily-notes.json, synced to the server via SYNC_CONFIGS -# below; falls back to "Daily Notes" and YYYY-MM-DD). Folder is any -# vault-relative path (Journal, Planner/Daily); format takes the same tokens -# as Obsidian's date format setting. +# Daily notes folder and filename format. Default: read from the vault's +# .obsidian/daily-notes.json, synced to the server via SYNC_CONFIGS below, or +# "Daily Notes" and YYYY-MM-DD when that file is missing or unreadable. An +# empty folder in that file means Obsidian's location for new notes, which +# syncs only when SYNC_CONFIGS includes app; without it, the server uses the +# vault root. Folder is any vault-relative path (Journal, Planner/Daily), or +# / for the vault root; format takes the same tokens as Obsidian's date format +# setting. # DAILY_NOTES_FOLDER=Journal # DAILY_NOTES_FORMAT=YYYY-MM-DD # Comma-separated folders protected from deletion and moves. Default: MEMORY_DIR plus # the daily notes folder, read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json -# (default "Daily Notes"). When set, replaces the whole default — include the -# daily notes folder in your list if needed. +# (default "Daily Notes"; none when that setting is the vault root or empty). +# When set, replaces the whole default — include the daily notes folder in +# your list if needed. # PROTECTED_PATHS=About Me,Daily Notes # Comma-separated folders excluded from orphan detection. # Default: the daily notes folder, Templates, and MEMORY_DIR. # DAILY_NOTES_FOLDER wins; otherwise .obsidian/daily-notes.json is reread -# on each query, falling back to "Daily Notes". Root-level daily notes remain eligible. +# on each query, falling back to "Daily Notes". A vault-root or empty folder +# setting adds no daily folder, so those daily notes remain eligible. # When set, replaces the whole default — include every folder to keep excluded. # Set ORPHAN_EXCLUDE_FOLDERS=, to exclude nothing; an empty value uses the defaults. # To apply environment changes, run the CLI's restart command @@ -347,8 +356,8 @@ SYNC_EXCLUDED_FOLDERS= SYNC_FILE_TYPES= # Obsidian settings categories to sync into the server's .obsidian/ folder -# (default: the two the server reads — daily notes settings and community -# plugin settings such as the Tasks plugin's format; "none" disables). +# (default: daily notes settings and community plugin settings such as the +# Tasks plugin's format; "none" disables). # A category only syncs after your desktop pushes it: Obsidian Settings → # Sync → "Vault configuration sync" (per device). Some community plugins # keep API keys in their settings — server tools never read .obsidian/, diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 2b9a1f0cc..308d77832 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -55,6 +55,10 @@ type OptionalSetting = /** Matches Moment.js [...] literal escape groups, splitting format spans from literal content. */ const MOMENT_BRACKET_ESCAPE = /\[([^\]]*)\]/g +/** Matches a value made only of forward or back slashes, which the server + * reads as the vault root. */ +const VAULT_ROOT_FOLDER_RE = /^[\\/]+$/ + // The curated prompt set — settings users most often want without reading // .env comments. Everything else stays documented-only in the generated // optional block, deliberately: every extra prompt costs init flow length. @@ -80,6 +84,8 @@ const OPTIONAL_SETTINGS: OptionalSetting[] = [ question: "Vault folder for daily notes:", placeholder: "blank = use your vault's daily notes settings", validate: (value) => { + // "/" names the vault root, so the absolute-path check below must not catch it. + if (VAULT_ROOT_FOLDER_RE.test(value)) return undefined if (value.includes("..")) return "Path traversal (..) is not allowed in folder names." if (value.startsWith("/")) return "Absolute paths are not allowed — use a vault-relative folder name." diff --git a/deploy/local/.env.example b/deploy/local/.env.example index 7e4fde2dd..14abdb262 100644 --- a/deploy/local/.env.example +++ b/deploy/local/.env.example @@ -84,23 +84,27 @@ READONLY_MODE=false # Memory folder name in your vault (default: About Me). MEMORY_DIR=About Me -# Daily notes folder and filename format (default: read from the -# vault's .obsidian/daily-notes.json, falling back to "Daily Notes" and -# YYYY-MM-DD). Folder is any vault-relative path (Journal, Planner/Daily); -# format takes the same tokens as Obsidian's date format setting. +# Daily notes folder and filename format. Default: read from the vault's +# .obsidian/daily-notes.json, or "Daily Notes" and YYYY-MM-DD when that file +# is missing or unreadable. An empty folder in that file means Obsidian's +# location for new notes. Folder is any vault-relative path (Journal, +# Planner/Daily), or / for the vault root; format takes the same tokens as +# Obsidian's date format setting. # DAILY_NOTES_FOLDER=Journal # DAILY_NOTES_FORMAT=YYYY-MM-DD # Comma-separated folders protected from deletion and moves. Default: MEMORY_DIR plus # the daily notes folder, read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json -# (default "Daily Notes"). When set, replaces the whole default — include the -# daily notes folder in your list if needed. +# (default "Daily Notes"; none when that setting is the vault root or empty). +# When set, replaces the whole default — include the daily notes folder in +# your list if needed. # PROTECTED_PATHS=About Me,Daily Notes # Comma-separated folders excluded from orphan detection. # Default: the daily notes folder, Templates, and MEMORY_DIR. # DAILY_NOTES_FOLDER wins; otherwise .obsidian/daily-notes.json is reread -# on each query, falling back to "Daily Notes". Root-level daily notes remain eligible. +# on each query, falling back to "Daily Notes". A vault-root or empty folder +# setting adds no daily folder, so those daily notes remain eligible. # When set, replaces the whole default — include every folder to keep excluded. # Set ORPHAN_EXCLUDE_FOLDERS=, to exclude nothing; an empty value uses the defaults. # To apply environment changes, run the CLI's restart command diff --git a/deploy/local/docker-compose.yml b/deploy/local/docker-compose.yml index 497754838..5d7020a8d 100644 --- a/deploy/local/docker-compose.yml +++ b/deploy/local/docker-compose.yml @@ -54,8 +54,10 @@ services: # (/ below are the resolved values, defaults "About Me" / "Daily Notes"): # PROTECTED_PATHS default: ", " (blocked from vault_delete_note) # ORPHAN_EXCLUDE_FOLDERS default: ", Templates, " (excluded from vault_find_orphans) + # (neither default adds a daily folder when the daily notes folder setting is the vault root or empty) # SERVICE_DOCUMENTATION_URL default: https://github.com/aliasunder/vault-cortex - # DAILY_NOTES_FOLDER default: read from the vault's .obsidian/daily-notes.json, else "Daily Notes" + # DAILY_NOTES_FOLDER default: read from the vault's .obsidian/daily-notes.json (an empty folder + # there follows Obsidian's new-note location), else "Daily Notes"; set / for the vault root # DAILY_NOTES_FORMAT default: read from the vault's .obsidian/daily-notes.json, else "YYYY-MM-DD" PROTECTED_PATHS: ${PROTECTED_PATHS:-} ORPHAN_EXCLUDE_FOLDERS: ${ORPHAN_EXCLUDE_FOLDERS:-} diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example index 04fe00eda..f7521b69e 100644 --- a/deploy/remote/.env.example +++ b/deploy/remote/.env.example @@ -89,24 +89,29 @@ READONLY_MODE=false # Memory folder name in your vault (default: About Me). MEMORY_DIR=About Me -# Daily notes folder and filename format (default: read from the -# vault's .obsidian/daily-notes.json, synced to the server via SYNC_CONFIGS -# below; falls back to "Daily Notes" and YYYY-MM-DD). Folder is any -# vault-relative path (Journal, Planner/Daily); format takes the same tokens -# as Obsidian's date format setting. +# Daily notes folder and filename format. Default: read from the vault's +# .obsidian/daily-notes.json, synced to the server via SYNC_CONFIGS below, or +# "Daily Notes" and YYYY-MM-DD when that file is missing or unreadable. An +# empty folder in that file means Obsidian's location for new notes, which +# syncs only when SYNC_CONFIGS includes app; without it, the server uses the +# vault root. Folder is any vault-relative path (Journal, Planner/Daily), or +# / for the vault root; format takes the same tokens as Obsidian's date format +# setting. # DAILY_NOTES_FOLDER=Journal # DAILY_NOTES_FORMAT=YYYY-MM-DD # Comma-separated folders protected from deletion and moves. Default: MEMORY_DIR plus # the daily notes folder, read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json -# (default "Daily Notes"). When set, replaces the whole default — include the -# daily notes folder in your list if needed. +# (default "Daily Notes"; none when that setting is the vault root or empty). +# When set, replaces the whole default — include the daily notes folder in +# your list if needed. # PROTECTED_PATHS=About Me,Daily Notes # Comma-separated folders excluded from orphan detection. # Default: the daily notes folder, Templates, and MEMORY_DIR. # DAILY_NOTES_FOLDER wins; otherwise .obsidian/daily-notes.json is reread -# on each query, falling back to "Daily Notes". Root-level daily notes remain eligible. +# on each query, falling back to "Daily Notes". A vault-root or empty folder +# setting adds no daily folder, so those daily notes remain eligible. # When set, replaces the whole default — include every folder to keep excluded. # Set ORPHAN_EXCLUDE_FOLDERS=, to exclude nothing; an empty value uses the defaults. # To apply environment changes, run the CLI's restart command @@ -194,8 +199,8 @@ SYNC_EXCLUDED_FOLDERS= SYNC_FILE_TYPES= # Obsidian settings categories to sync into the server's .obsidian/ folder -# (default: the two the server reads — daily notes settings and community -# plugin settings such as the Tasks plugin's format; "none" disables). +# (default: daily notes settings and community plugin settings such as the +# Tasks plugin's format; "none" disables). # A category only syncs after your desktop pushes it: Obsidian Settings → # Sync → "Vault configuration sync" (per device). Some community plugins # keep API keys in their settings — server tools never read .obsidian/, diff --git a/deploy/remote/README.md b/deploy/remote/README.md index d86b86bf8..2e433e087 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -539,29 +539,50 @@ in `.obsidian/daily-notes.json`, and in remote mode the server only receives that file when settings syncing is turned on in two places: - **Server side** — `SYNC_CONFIGS` defaults to - `core-plugin-data,community-plugin-data`, the two categories the server - reads: daily notes settings and community plugin settings (e.g. the Tasks - plugin's format). It takes a comma-separated list of Obsidian's settings - categories (`app`, `appearance`, `appearance-data`, `hotkey`, - `core-plugin`, `core-plugin-data`, `community-plugin`, - `community-plugin-data`); set `SYNC_CONFIGS=none` in `.env` to disable. + `core-plugin-data,community-plugin-data`: daily notes settings (Obsidian's + **Core plugin settings** toggle) and community plugin settings, such as + the Tasks plugin's format (**Installed community plugins**). It takes a + comma-separated list of Obsidian's settings categories (`app`, + `appearance`, `appearance-data`, `hotkey`, `core-plugin`, + `core-plugin-data`, `community-plugin`, `community-plugin-data`); set + `SYNC_CONFIGS=none` in `.env` to disable. - **Desktop side** — Obsidian Settings → Sync → **Vault configuration sync**, enabled per device. Obsidian only pushes categories you enable there. Some community plugins keep API keys in their settings. Server tools never -read `.obsidian/`, and the server itself opens only the two config files -above — synced settings otherwise just sit in the config volume. +read `.obsidian/`, and the server itself opens only `daily-notes.json`, the +Tasks plugin's settings, and `app.json` (for the new-note location, when the +daily notes folder is blank) — other synced settings just sit in the vault's +`.obsidian/` folder. If you turn on **Core plugin settings** syncing after the server is already running, `daily-notes.json` is picked up automatically once it lands; no restart needed. +If the folder box in Obsidian's Daily notes options is empty, Obsidian puts +daily notes where **Default location for new notes** says, a setting stored +in `.obsidian/app.json`. The default `SYNC_CONFIGS` leaves that file out, so +the server then looks in the vault root. **Same folder as current file** also +means the vault root, as it does in Obsidian for daily notes. If your new +notes go to a set folder, either: + +- set `SYNC_CONFIGS=core-plugin-data,community-plugin-data,app`, the default + list plus `app`, and turn on **Main settings** under Vault configuration + sync — a `.env` edit, so re-create the container as described below; or +- set `DAILY_NOTES_FOLDER`, as described below. + +Daily notes in the vault root, or in the new-note folder because the folder +box is empty, aren't protected from delete and move and can appear in orphan +results; the [main README's Daily notes section](../../README.md#daily-notes) +explains why. + If the settings can't sync — or you use the Periodic Notes plugin, whose settings the core config file doesn't track — set `DAILY_NOTES_FOLDER` (any -vault-relative path: `Journal`, `Planner/Daily`) and `DAILY_NOTES_FORMAT` -(same tokens as Obsidian's date format setting) in `.env`. You can set one -or both — a set value always wins over the config file. Without either -source, the server falls back to the `Daily Notes` folder and `YYYY-MM-DD`. +vault-relative path: `Journal`, `Planner/Daily`, or `/` for the vault root) +and `DAILY_NOTES_FORMAT` (same tokens as Obsidian's date format setting) in +`.env`. You can set one or both — a set value always wins over the config +file. A value you leave unset while `daily-notes.json` hasn't reached the +server comes from the fallbacks: the `Daily Notes` folder and `YYYY-MM-DD`. `.env` edits take effect when the container is re-created: set up with the CLI, run `npx vault-cortex@latest restart`; with Compose, `docker compose up -d`; diff --git a/deploy/remote/docker-compose.yml b/deploy/remote/docker-compose.yml index dc34a1ac5..e13d1e649 100644 --- a/deploy/remote/docker-compose.yml +++ b/deploy/remote/docker-compose.yml @@ -70,8 +70,10 @@ services: # (/ below are the resolved values, defaults "About Me" / "Daily Notes"): # PROTECTED_PATHS default: ", " (blocked from vault_delete_note) # ORPHAN_EXCLUDE_FOLDERS default: ", Templates, " (excluded from vault_find_orphans) + # (neither default adds a daily folder when the daily notes folder setting is the vault root or empty) # SERVICE_DOCUMENTATION_URL default: https://github.com/aliasunder/vault-cortex - # DAILY_NOTES_FOLDER default: read from the vault's .obsidian/daily-notes.json, else "Daily Notes" + # DAILY_NOTES_FOLDER default: read from the vault's .obsidian/daily-notes.json (an empty folder + # there follows Obsidian's new-note location), else "Daily Notes"; set / for the vault root # DAILY_NOTES_FORMAT default: read from the vault's .obsidian/daily-notes.json, else "YYYY-MM-DD" PROTECTED_PATHS: ${PROTECTED_PATHS:-} ORPHAN_EXCLUDE_FOLDERS: ${ORPHAN_EXCLUDE_FOLDERS:-} diff --git a/docker-compose.local.yml b/docker-compose.local.yml index f079f8252..b464a7bdf 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -52,8 +52,10 @@ services: # (/ below are the resolved values, defaults "About Me" / "Daily Notes"): # PROTECTED_PATHS default: ", " # ORPHAN_EXCLUDE_FOLDERS default: ", Templates, " + # (neither default adds a daily folder when the daily notes folder setting is the vault root or empty) # SERVICE_DOCUMENTATION_URL default: https://github.com/aliasunder/vault-cortex - # DAILY_NOTES_FOLDER default: read from the vault's .obsidian/daily-notes.json, else "Daily Notes" + # DAILY_NOTES_FOLDER default: read from the vault's .obsidian/daily-notes.json (an empty folder + # there follows Obsidian's new-note location), else "Daily Notes"; set / for the vault root # DAILY_NOTES_FORMAT default: read from the vault's .obsidian/daily-notes.json, else "YYYY-MM-DD" PROTECTED_PATHS: ${PROTECTED_PATHS:-} ORPHAN_EXCLUDE_FOLDERS: ${ORPHAN_EXCLUDE_FOLDERS:-} diff --git a/docker-compose.yml b/docker-compose.yml index 6fc8b95ac..b219bf403 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -101,11 +101,14 @@ services: # stand for the resolved values (defaults "About Me" and "Daily Notes"). # A set folder list replaces its whole default. # The daily notes folder is resolved from DAILY_NOTES_FOLDER or - # .obsidian/daily-notes.json, falling back to "Daily Notes". + # .obsidian/daily-notes.json (an empty folder there follows Obsidian's + # new-note location), falling back to "Daily Notes" when that file is missing. # PROTECTED_PATHS default: ", " # ORPHAN_EXCLUDE_FOLDERS default: ", Templates, " + # (neither default adds a daily folder when the daily notes folder setting is the vault root or empty) # SERVICE_DOCUMENTATION_URL default: https://github.com/aliasunder/vault-cortex - # DAILY_NOTES_FOLDER default: read from the vault's .obsidian/daily-notes.json, else "Daily Notes" + # DAILY_NOTES_FOLDER default: read from the vault's .obsidian/daily-notes.json (an empty folder + # there follows Obsidian's new-note location), else "Daily Notes"; set / for the vault root # DAILY_NOTES_FORMAT default: read from the vault's .obsidian/daily-notes.json, else "YYYY-MM-DD" PROTECTED_PATHS: ${PROTECTED_PATHS:-} ORPHAN_EXCLUDE_FOLDERS: ${ORPHAN_EXCLUDE_FOLDERS:-} diff --git a/server.json b/server.json index f1dd150c0..62d820ae3 100644 --- a/server.json +++ b/server.json @@ -118,7 +118,7 @@ }, { "name": "DAILY_NOTES_FOLDER", - "description": "Vault folder for daily notes. Overrides the folder configured in Obsidian's daily-notes plugin. When unset, read from the vault's .obsidian/daily-notes.json, falling back to \"Daily Notes\".", + "description": "Vault folder for daily notes. Overrides the folder configured in Obsidian's daily-notes plugin. When unset, read from the vault's .obsidian/daily-notes.json (an empty folder there follows Obsidian's new-note location), falling back to \"Daily Notes\" when that file is missing. Set to / for the vault root.", "default": "Daily Notes" }, { @@ -160,11 +160,11 @@ }, { "name": "PROTECTED_PATHS", - "description": "Comma-separated vault folder names blocked from vault_delete_note and vault_move_note. Default: MEMORY_DIR plus the daily notes folder, read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json (default Daily Notes). When set, overrides the default entirely." + "description": "Comma-separated vault folder names blocked from vault_delete_note and vault_move_note. Default: MEMORY_DIR plus the daily notes folder, read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json (default Daily Notes; none when that setting is the vault root or empty). When set, overrides the default entirely." }, { "name": "ORPHAN_EXCLUDE_FOLDERS", - "description": "Comma-separated vault folder names excluded from vault_find_orphans. Default: the daily notes folder, \"Templates\", and MEMORY_DIR. DAILY_NOTES_FOLDER wins; otherwise .obsidian/daily-notes.json is read per query (fallback \"Daily Notes\"). When set, replaces the defaults entirely. Set to a comma (,) to exclude nothing; an empty value uses the defaults." + "description": "Comma-separated vault folder names excluded from vault_find_orphans. Default: the daily notes folder, \"Templates\", and MEMORY_DIR. DAILY_NOTES_FOLDER wins; otherwise .obsidian/daily-notes.json is read per query (fallback \"Daily Notes\"; no daily folder when that setting is the vault root or empty). When set, replaces the defaults entirely. Set to a comma (,) to exclude nothing; an empty value uses the defaults." }, { "name": "SERVICE_DOCUMENTATION_URL", diff --git a/src/vault-mcp/__tests__/config.test.ts b/src/vault-mcp/__tests__/config.test.ts index 517c60c5b..2577ed7d4 100644 --- a/src/vault-mcp/__tests__/config.test.ts +++ b/src/vault-mcp/__tests__/config.test.ts @@ -159,6 +159,16 @@ describe("loadConfig", () => { "absolute paths not allowed", ) }) + + it.each([ + { label: "a lone slash", input: "/" }, + { label: "a slash with surrounding spaces", input: " / " }, + { label: "repeated slashes", input: "//" }, + { label: "a backslash", input: "\\" }, + ])("reads $label as the vault root", ({ input }) => { + const config = loadConfig({ DAILY_NOTES_FOLDER: input }) + expect(config.dailyNotesFolder).toBe("") + }) }) describe("DAILY_NOTES_FORMAT", () => { diff --git a/src/vault-mcp/config.ts b/src/vault-mcp/config.ts index b5b2d2076..f88070438 100644 --- a/src/vault-mcp/config.ts +++ b/src/vault-mcp/config.ts @@ -25,6 +25,16 @@ const vaultFolderName = z .refine((value) => !value.startsWith("/"), "absolute paths not allowed"), ) +/** Matches a value made only of forward or back slashes, which Obsidian reads + * as the vault root. */ +const VAULT_ROOT_FOLDER_RE = /^[\\/]+$/ + +/** Parses DAILY_NOTES_FOLDER. "/", the value Obsidian saves for the vault + * root, loads as ""; anything else must be a vault folder name. */ +const parseDailyNotesFolder = (folder: string): string => { + return VAULT_ROOT_FOLDER_RE.test(folder) ? "" : vaultFolderName.parse(folder) +} + /** Splits a comma-separated env value into its entries. * Trims each entry; empty entries (from trailing commas) are filtered out. */ const splitCommaSeparatedValues = (raw: string): string[] => @@ -112,7 +122,8 @@ export type VaultConfig = Readonly<{ /** Sets the daily notes folder, taking precedence over * .obsidian/daily-notes.json. * Per-field precedence: env setting → daily-notes.json → fallback - * ("Daily Notes"). Set via DAILY_NOTES_FOLDER. */ + * ("Daily Notes"). Set via DAILY_NOTES_FOLDER; "/" (the vault root) loads + * as "", so test `!== undefined`, not truthiness, for a set value. */ dailyNotesFolder?: string | undefined /** Sets the daily notes filename format (moment tokens, matching * Obsidian's setting), taking precedence over .obsidian/daily-notes.json. @@ -174,7 +185,7 @@ export const loadConfig = (env: Record = process.env const dailyNotesFolderRaw = env.DAILY_NOTES_FOLDER?.trim() const dailyNotesFolder = dailyNotesFolderRaw - ? vaultFolderName.parse(dailyNotesFolderRaw) + ? parseDailyNotesFolder(dailyNotesFolderRaw) : undefined const dailyNotesFormatRaw = env.DAILY_NOTES_FORMAT?.trim() 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 2519b62c0..cd396e169 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 @@ -194,7 +194,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- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path), \"EACCES: …\" or another filesystem error code — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"ENAMETOOLONG: …\" — the path, or a name in it, is longer than the filesystem allows; check the path\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -271,7 +271,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -334,7 +334,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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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": { @@ -394,7 +394,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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { @@ -951,7 +951,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- \"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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\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 cannot be moved: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. Saving stages a temp file 41 bytes longer than new_path's name, so a long name fails; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\" — old_path or new_path, or a name in it, is longer than the filesystem allows; check the path.\n- \"EACCES: …\" or another filesystem error code — a note or folder can't be read or written (permissions, a full or read-only disk); nothing was written; ask the vault's owner to fix it.\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": { 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 29f788b54..620ab36aa 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 @@ -196,7 +196,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- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path), \"EACCES: …\" or another filesystem error code — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"ENAMETOOLONG: …\" — the path, or a name in it, is longer than the filesystem allows; check the path\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -273,7 +273,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -336,7 +336,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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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": { @@ -396,7 +396,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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { @@ -953,7 +953,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- \"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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\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 cannot be moved: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. Saving stages a temp file 41 bytes longer than new_path's name, so a long name fails; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\" — old_path or new_path, or a name in it, is longer than the filesystem allows; check the path.\n- \"EACCES: …\" or another filesystem error code — a note or folder can't be read or written (permissions, a full or read-only disk); nothing was written; ask the vault's owner to fix it.\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": { 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 effc9efd8..be887a1f3 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 @@ -196,7 +196,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- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path), \"EACCES: …\" or another filesystem error code — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"ENAMETOOLONG: …\" — the path, or a name in it, is longer than the filesystem allows; check the path\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -273,7 +273,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -336,7 +336,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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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": { @@ -396,7 +396,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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { @@ -953,7 +953,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- \"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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\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 cannot be moved: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. Saving stages a temp file 41 bytes longer than new_path's name, so a long name fails; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\" — old_path or new_path, or a name in it, is longer than the filesystem allows; check the path.\n- \"EACCES: …\" or another filesystem error code — a note or folder can't be read or written (permissions, a full or read-only disk); nothing was written; ask the vault's owner to fix it.\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": { 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 92905468a..5d867a90d 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 @@ -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- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path), \"EACCES: …\" or another filesystem error code — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"ENAMETOOLONG: …\" — the path, or a name in it, is longer than the filesystem allows; check the path\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -274,7 +274,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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": { @@ -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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { @@ -913,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- \"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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\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 cannot be moved: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. Saving stages a temp file 41 bytes longer than new_path's name, so a long name fails; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\" — old_path or new_path, or a name in it, is longer than the filesystem allows; check the path.\n- \"EACCES: …\" or another filesystem error code — a note or folder can't be read or written (permissions, a full or read-only disk); nothing was written; ask the vault's owner to fix it.\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": { 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 d284c4b8c..d2693038f 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 @@ -196,7 +196,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- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path), \"EACCES: …\" or another filesystem error code — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"ENAMETOOLONG: …\" — the path, or a name in it, is longer than the filesystem allows; check the path\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -273,7 +273,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -336,7 +336,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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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": { @@ -396,7 +396,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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { @@ -912,7 +912,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- \"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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\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 cannot be moved: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. Saving stages a temp file 41 bytes longer than new_path's name, so a long name fails; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\" — old_path or new_path, or a name in it, is longer than the filesystem allows; check the path.\n- \"EACCES: …\" or another filesystem error code — a note or folder can't be read or written (permissions, a full or read-only disk); nothing was written; ask the vault's owner to fix it.\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": { 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 8b639743f..5dc3da02b 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 @@ -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- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path), \"EACCES: …\" or another filesystem error code — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"ENAMETOOLONG: …\" — the path, or a name in it, is longer than the filesystem allows; check the path\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -228,7 +228,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { @@ -814,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- \"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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\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 cannot be moved: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. Saving stages a temp file 41 bytes longer than new_path's name, so a long name fails; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\" — old_path or new_path, or a name in it, is longer than the filesystem allows; check the path.\n- \"EACCES: …\" or another filesystem error code — a note or folder can't be read or written (permissions, a full or read-only disk); nothing was written; ask the vault's owner to fix it.\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": { 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 ffac4f220..281891409 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 @@ -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- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path), \"EACCES: …\" or another filesystem error code — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"ENAMETOOLONG: …\" — the path, or a name in it, is longer than the filesystem allows; check the path\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -229,7 +229,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { @@ -774,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- \"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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\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 cannot be moved: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. Saving stages a temp file 41 bytes longer than new_path's name, so a long name fails; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\" — old_path or new_path, or a name in it, is longer than the filesystem allows; check the path.\n- \"EACCES: …\" or another filesystem error code — a note or folder can't be read or written (permissions, a full or read-only disk); nothing was written; ask the vault's owner to fix it.\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": { 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 f11266c8d..2cb1464e8 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 @@ -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- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path), \"EACCES: …\" or another filesystem error code — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"ENAMETOOLONG: …\" — the path, or a name in it, is longer than the filesystem allows; check the path\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -228,7 +228,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { @@ -773,7 +773,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- \"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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\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 cannot be moved: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. Saving stages a temp file 41 bytes longer than new_path's name, so a long name fails; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\" — old_path or new_path, or a name in it, is longer than the filesystem allows; check the path.\n- \"EACCES: …\" or another filesystem error code — a note or folder can't be read or written (permissions, a full or read-only disk); nothing was written; ask the vault's owner to fix it.\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": { 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 6e7ef8f03..bb6e2421b 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 @@ -150,7 +150,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- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note, moving it to the vault's .trash/ folder or removing it for good as the vault's Obsidian \"Deleted files\" setting directs.\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- The \"Deleted files\" setting (`trashOption` in `.obsidian/app.json`) decides the outcome:\n - \"Move to system trash\" (`system`, also what an absent setting means) moves the note to `.trash/`, since the server has no system trash. Notes this server moved there are deleted after its retention period (TRASH_RETENTION_DAYS: 30 days by default, never when set to none); notes Obsidian itself trashed are never touched.\n - \"Move to Obsidian trash\" (`local`) moves the note to `.trash/` and keeps it forever.\n - \"Permanently delete\" (`none`) removes the note for good.\n- The caller can't choose or see the outcome in advance; the returned message says which happened.\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot move to trash … — 100 collisions in .trash/\" — .trash/ holds this name and 100 numbered copies (\"Plan 1.md\" … \"Plan 100.md\"); ask the vault owner to clear old copies, then retry\n- any other \"cannot move to trash …\" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry\n- \"cannot delete …\" (other than a protected path), \"EACCES: …\" or another filesystem error code — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"ENAMETOOLONG: …\" — the path, or a name in it, is longer than the filesystem allows; check the path\n- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message naming the outcome — \"Deleted \" for permanent removal, \"Moved to trash (<.trash/ path>)\" when the note landed in .trash/. Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -227,7 +227,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -290,7 +290,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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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": { @@ -315,7 +315,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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { @@ -813,7 +813,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- \"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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\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 cannot be moved: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. Saving stages a temp file 41 bytes longer than new_path's name, so a long name fails; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\" — old_path or new_path, or a name in it, is longer than the filesystem allows; check the path.\n- \"EACCES: …\" or another filesystem error code — a note or folder can't be read or written (permissions, a full or read-only disk); nothing was written; ask the vault's owner to fix it.\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": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json index 746a63c04..eb873550b 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json @@ -196,7 +196,7 @@ { "name": "vault_delete_note", "title": "Delete Note", - "description": "Delete a markdown note for good: this server syncs through Obsidian Sync, so it bypasses the vault's \"Deleted files\" setting; recover a deleted note from Sync's version history (1 month on Standard, 12 months on Plus).\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot delete …\" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message — \"Deleted \". Notes how many empty folders were pruned when any were.", + "description": "Delete a markdown note for good: this server syncs through Obsidian Sync, so it bypasses the vault's \"Deleted files\" setting; recover a deleted note from Sync's version history (1 month on Standard, 12 months on Plus).\n\nExample: vault_delete_note({ path: \"Scratch/temp.md\" })\nExample: vault_delete_note({ path: \"Archive/2024/old.md\", prune_empty_folders: true }) — also remove \"Archive/2024\" (and \"Archive\") if deleting the note empties them.\n\nWhen to use: Removing a note you no longer need.\nPrefer vault_delete_memory for removing individual dated entries from About Me/ memory files.\nTo relocate a note, use vault_move_note instead.\nTo replace a note's content, use vault_write_note with overwrite: true instead.\n\nBehavior:\n- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\n\nParameters:\n- prune_empty_folders removes each parent folder the delete leaves with zero entries, up to but never including the vault root; a folder holding any file, even a hidden .DS_Store, is kept. Pruning runs after the delete or trash move and is best-effort: a folder that can't be removed never fails the call. Without it, empty folders stay, matching Obsidian.\n\nErrors:\n- \"cannot delete protected path\" — the path sits under a protected folder; use vault_delete_memory for memory entries\n- \"path must end in …\" — add the .md extension\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it\n- \"concurrent write in progress\" — another write to this note is in flight; retry\n- \"note not found: …\" — verify path with vault_list_notes\n- \"cannot delete …\" (other than a protected path), \"EACCES: …\" or another filesystem error code — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry\n- \"ENAMETOOLONG: …\" — the path, or a name in it, is longer than the filesystem allows; check the path\n- \"cannot read daily notes config from .obsidian/daily-notes.json\" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry\n\nReturns: Confirmation message — \"Deleted \". Notes how many empty folders were pruned when any were.", "inputSchema": { "type": "object", "properties": { @@ -273,7 +273,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -336,7 +336,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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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": { @@ -396,7 +396,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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { @@ -953,7 +953,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- \"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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\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 cannot be moved: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).\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- \"move aborted: could not read …\" / \"move aborted: could not rewrite …\" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry.\n- \"move aborted: could not write to …\" — new_path could not be created; nothing was written. Saving stages a temp file 41 bytes longer than new_path's name, so a long name fails; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it.\n- \"ENAMETOOLONG: …\" — old_path or new_path, or a name in it, is longer than the filesystem allows; check the path.\n- \"EACCES: …\" or another filesystem error code — a note or folder can't be read or written (permissions, a full or read-only disk); nothing was written; ask the vault's owner to fix it.\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": { 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 9bfacb887..328575a75 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,7 +9,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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": { @@ -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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { 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 6e3a5baf8..8ce8fe54c 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,7 +10,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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": { @@ -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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { 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 075239886..f2f0c3b80 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,7 +9,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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": { @@ -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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { 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 bc7f83bdf..9d620a3a8 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,7 +10,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { 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 e0fed47f2..497b63d10 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,7 +11,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { 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 d6253d90a..e9b5a05ce 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,7 +10,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { 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 c09c73e4e..7443bc7e4 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,7 +9,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { 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 dd134ed8c..e58d8468a 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,7 +8,7 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable).\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else \"Daily Notes\" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.\n- exclude_folders replaces the defaults, it does not add to them — list a default yourself to keep it (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one). Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { @@ -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.\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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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. 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- \"… is not a calendar date\" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date\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- \"path traversal blocked\" / \"hidden path blocked\" / \"absolute path blocked\" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT\n- \"EACCES: …\" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it\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": { @@ -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\"). 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.", + "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, unless the daily notes folder setting is the vault root or blank — 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": { 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 1db767a7a..8fad9a9f1 100644 --- a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts @@ -517,7 +517,7 @@ describe("config interpolation in descriptions", () => { linePrefix: "- Links to the note", }) expect(linksEntry).toBe( - "- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: Profile/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/).", + "- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: Profile/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank).", ) }) @@ -1114,9 +1114,11 @@ describe("vault_delete_note description reflects OBSIDIAN_SYNC", () => { '- "note not found: …" — verify path with vault_list_notes', ] const OTHER_DELETE_ERROR_ENTRY = - '- "cannot delete …" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry' + '- "cannot delete …" (other than a protected path), "EACCES: …" or another filesystem error code — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry' const DAILY_NOTES_CONFIG_ERROR_ENTRY = '- "cannot read daily notes config from .obsidian/daily-notes.json" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry' + const NAME_TOO_LONG_ERROR_ENTRY = + '- "ENAMETOOLONG: …" — the path, or a name in it, is longer than the filesystem allows; check the path' it("lists the trash-move and trash-setting errors when the server does not sync", () => { expect(deleteNoteErrors({})).toBe( @@ -1126,6 +1128,7 @@ describe("vault_delete_note description reflects OBSIDIAN_SYNC", () => { '- "cannot move to trash … — 100 collisions in .trash/" — .trash/ holds this name and 100 numbered copies ("Plan 1.md" … "Plan 100.md"); ask the vault owner to clear old copies, then retry', '- any other "cannot move to trash …" — the note stays put; ask the vault owner to fix .trash/ (e.g. a plain file blocks a needed folder), then retry', OTHER_DELETE_ERROR_ENTRY, + NAME_TOO_LONG_ERROR_ENTRY, "- \"cannot read trash config from .obsidian/app.json\" — the file exists but can't be read or parsed, and guessing the setting could let the server's trash cleanup delete a note Obsidian keeps forever; ask the vault owner to repair it, then retry", DAILY_NOTES_CONFIG_ERROR_ENTRY, ].join("\n"), @@ -1138,6 +1141,7 @@ describe("vault_delete_note description reflects OBSIDIAN_SYNC", () => { "Errors:", ...PATH_AND_LOOKUP_ERROR_ENTRIES, OTHER_DELETE_ERROR_ENTRY, + NAME_TOO_LONG_ERROR_ENTRY, DAILY_NOTES_CONFIG_ERROR_ENTRY, ].join("\n"), ) @@ -1158,7 +1162,7 @@ describe("vault_delete_note description reflects OBSIDIAN_SYNC", () => { }) } const LINKS_AND_PROTECTED_PATHS_ENTRY = - "- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)." + "- Links to the note from other notes become broken; list them first with vault_get_backlinks. Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank)." it("describes the Deleted files outcomes when the server does not sync", () => { expect(deleteNoteOpener({})).toBe( @@ -1838,7 +1842,7 @@ describe("vault_find_orphans live folder defaults", () => { ?.split("\n") .find((line) => line.startsWith("- With exclude_folders")) expect(defaultsLine).toBe( - '- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else "Daily Notes" (also used when that file is unreadable).', + '- With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else "Daily Notes" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.', ) expect(toolConfig.description).not.toContain("Journal") expect(toolConfig.inputSchema?.exclude_folders?.description).toBe( @@ -1874,6 +1878,65 @@ describe("vault_find_orphans live folder defaults", () => { }) }) +describe("vault_get_outgoing_links daily forward references", () => { + // Obsidian puts daily notes in the new-note folder (Inbox here) when the + // daily-notes.json folder is blank; that folder also holds other notes. + const NEW_NOTE_LOCATION = '{"newFileLocation":"folder","newFileFolderPath":"Inbox"}' + + it.each([ + { label: "a folder set to Inbox", settings: '{"folder":"Inbox"}', isForwardRef: true }, + { + label: "a blank folder that resolves to Inbox", + settings: '{"folder":""}', + isForwardRef: false, + }, + ])( + "sets daily_note_forward_ref to $isForwardRef on a missing Inbox note for $label", + async ({ settings, isForwardRef }) => { + const vaultPath = await mkdtemp(join(tmpdir(), "outgoing-links-handler-")) + onTestFinished(() => rm(vaultPath, { recursive: true, force: true })) + await mkdir(join(vaultPath, ".obsidian")) + await writeFile(join(vaultPath, ".obsidian/daily-notes.json"), settings) + await writeFile(join(vaultPath, ".obsidian/app.json"), NEW_NOTE_LOCATION) + const search = createSearchIndex(":memory:") + search.upsertNote( + { + filePath: "Inbox/2026-06-16.md", + rawContent: "[[Inbox/2026-06-17]]\n", + fileStat: { mtimeMs: 1000, size: 21 }, + }, + logger, + ) + const outgoingLinksCall = registerWithConfig({}, { vaultPath, search }).find( + ([name]) => name === TOOL_NAMES.VAULT_GET_OUTGOING_LINKS, + ) + + if (!outgoingLinksCall) throw new Error("vault_get_outgoing_links not registered") + + const result = z + .object({ content: z.array(z.object({ text: z.string() })) }) + .parse( + await outgoingLinksCall[2]({ path: "Inbox/2026-06-16.md" }, { requestId: "links-1" }), + ) + + expect(JSON.parse(requireTextContent(result))).toEqual({ + path: "Inbox/2026-06-16.md", + outgoing_links: [ + { + path: "Inbox/2026-06-17", + title: null, + exists: false, + kind: "note", + bytes: null, + daily_note_forward_ref: isForwardRef, + }, + ], + count: 1, + }) + }, + ) +}) + describe("vault_list_tasks handler", () => { const mockExtra = { requestId: "test-1", sessionId: "session-1" } @@ -2629,7 +2692,7 @@ describe("DISABLED_TOOLS", () => { }) } const PROTECTED_PATHS_SENTENCE = - " Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/)." + " Protected paths are refused: About Me/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to Daily Notes/; not protected when it is the vault root or blank)." expect(linksEntry("")).toBe( `- Links to the note from other notes become broken; list them first with vault_get_backlinks.${PROTECTED_PATHS_SENTENCE}`, @@ -2653,7 +2716,7 @@ describe("DISABLED_TOOLS", () => { '. Pass [] for no exclusions. Each entry names a whole folder, subfolders included ("Projects" also excludes "Projects/Archive" but not "ProjectsOld/"), ignoring ASCII letter case.' expect(excludeFoldersEntry("")).toBe( - `${ENTRY_START} (vault_get_daily_note's path starts with the daily notes folder)${ENTRY_END}`, + `${ENTRY_START} (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one)${ENTRY_END}`, ) expect(excludeFoldersEntry("vault_get_daily_note")).toBe(`${ENTRY_START}${ENTRY_END}`) }) 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 0b19f6954..7aa43c0f3 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 @@ -36,7 +36,7 @@ describe("tool surface baseline", () => { * - The allowance is about 2% above the default combo's average when the * cap was set (4,130 chars per tool), so only small growth passes. * Raising the allowance is a deliberate change with its reason in the PR. */ - const CHARS_PER_TOOL_ALLOWANCE = 4234 + const CHARS_PER_TOOL_ALLOWANCE = 4273 it.each(SIZE_CAPPED_COMBO_NAMES)( "combo %s stays within the average per-tool size allowance", diff --git a/src/vault-mcp/mcp-core/prompts/__tests__/daily-review-prompt.test.ts b/src/vault-mcp/mcp-core/prompts/__tests__/daily-review-prompt.test.ts index 8b79e3f33..935e3cabf 100644 --- a/src/vault-mcp/mcp-core/prompts/__tests__/daily-review-prompt.test.ts +++ b/src/vault-mcp/mcp-core/prompts/__tests__/daily-review-prompt.test.ts @@ -433,6 +433,44 @@ describe("daily-review handler", () => { ) }) + it("reads a blank-folder daily note from the new-note folder and counts its missing links there as broken", async () => { + const vault = await mkdtemp(join(tmpdir(), "prompt-daily-blank-folder-")) + onTestFinished(() => rm(vault, { recursive: true, force: true })) + await mkdir(join(vault, ".obsidian")) + await mkdir(join(vault, "Inbox")) + await mkdir(join(vault, "About Me")) + await writeFile(join(vault, ".obsidian", "daily-notes.json"), '{"folder":""}') + // The new-note folder also holds other notes, so a missing note there is + // not taken for a daily note that has not been created yet + await writeFile( + join(vault, ".obsidian", "app.json"), + '{"newFileLocation":"folder","newFileFolderPath":"Inbox"}', + ) + const dailyContent = "# 2026-06-16\n\n[[Inbox/2026-06-17]]\n" + await writeFile(join(vault, "Inbox", "2026-06-16.md"), dailyContent) + const search = createSearchIndex(":memory:") + search.upsertNote( + { + filePath: "Inbox/2026-06-16.md", + rawContent: dailyContent, + fileStat: { mtimeMs: JUNE_16_MIDDAY_MS, size: Buffer.byteLength(dailyContent) }, + }, + logger, + ) + const handler = findCall(registerWithSearch(vault, search), PROMPT_NAMES.DAILY_REVIEW)[2] + + const text = textOf(await handler({ date: "2026-06-16" }, fakeExtra)) + + expect({ + leadLine: reviewLeadLine(text), + outgoingLinks: reviewSection(text, "Outgoing links"), + }).toEqual({ + leadLine: "Daily note: `Inbox/2026-06-16.md`", + outgoingLinks: + "- Inbox/2026-06-17 (**broken** — target does not exist)\n\n1 broken link — the target note does not exist yet.", + }) + }) + it("shows backlinks when other notes reference the daily note", async () => { const vault = await mkdtemp(join(tmpdir(), "prompt-daily-back-")) onTestFinished(async () => { diff --git a/src/vault-mcp/mcp-core/prompts/__tests__/vault-orientation-prompt.test.ts b/src/vault-mcp/mcp-core/prompts/__tests__/vault-orientation-prompt.test.ts index 01b1fdb10..16b5d1897 100644 --- a/src/vault-mcp/mcp-core/prompts/__tests__/vault-orientation-prompt.test.ts +++ b/src/vault-mcp/mcp-core/prompts/__tests__/vault-orientation-prompt.test.ts @@ -116,6 +116,26 @@ describe("vault-orientation live orphan folders", () => { ) }) + it("neither excludes nor counts forward-refs in the new-note folder a blank daily folder resolves to", async () => { + const { vault, readSurveySections } = await setupOrphanPrompt({ + settings: '{"folder":""}', + paths: ["source.md", "Inbox/idea.md", "Templates/template.md"], + noteContents: { "source.md": "[[Inbox/2026-10-07]] [[missing]]" }, + }) + // Daily notes then go to Inbox alongside other new notes, so Inbox is not + // a daily-only folder + await writeFile( + join(vault, ".obsidian/app.json"), + '{"newFileLocation":"folder","newFileFolderPath":"Inbox"}', + ) + + expect(await readSurveySections()).toEqual({ + orphans: "2 orphan notes (no incoming links):\n- source.md — source\n- Inbox/idea.md — idea", + stats: + "3 notes across 0 folders, 0 tags, 0 property keys. 3 untagged. 3 without properties. 2 broken links.", + }) + }) + it("keeps ordinary orphans visible behind more than five newer excluded daily candidates", async () => { const dailyPaths = Array.from({ length: 7 }, (_, index) => `Journal/nested/day-${index}.md`) const { orphanSection } = await setupOrphanPrompt({ diff --git a/src/vault-mcp/mcp-core/prompts/daily-review-prompt.ts b/src/vault-mcp/mcp-core/prompts/daily-review-prompt.ts index 1a886a6be..afeeec881 100644 --- a/src/vault-mcp/mcp-core/prompts/daily-review-prompt.ts +++ b/src/vault-mcp/mcp-core/prompts/daily-review-prompt.ts @@ -198,8 +198,7 @@ export const registerDailyReviewPrompt = ({ } // Resolved once so the note path and the link classification below - // see the same settings; passing both fields makes getDailyNote skip - // its own read of daily-notes.json. + // see the same settings. const dailyNotesConfig = await readDailyNotesConfig( { vaultPath, @@ -208,7 +207,11 @@ export const registerDailyReviewPrompt = ({ reqLogger, ) const dailyNote = await getDailyNote( - { vaultPath, date: resolvedDate, envSettings: dailyNotesConfig }, + { + vaultPath, + date: resolvedDate, + settings: { source: "resolved", config: dailyNotesConfig }, + }, reqLogger, ) // One note past the cap tells the review whether it lists every note @@ -221,7 +224,7 @@ export const registerDailyReviewPrompt = ({ ? search.getOutgoingLinks( { path: dailyNote.path, - dailyNotesFolder: dailyNotesConfig.folder, + dailyNotesFolder: dailyNotesConfig.dedicatedFolder, }, reqLogger, ) diff --git a/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts b/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts index c435e3ed2..025001db1 100644 --- a/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts +++ b/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts @@ -104,9 +104,12 @@ const formatExcludedForwardRefs = (params: { }): string => { const { excludedFolder, excludedCount } = params - if (excludedCount === 0) return "" + // brokenLinkCount excludes nothing without a folder, so a null folder + // always comes with a zero count. + if (excludedFolder === null || excludedCount === 0) return "" - // Collapses a trailing slash the folder name may already carry + // brokenLinkCount echoes the folder as its caller passed it, which may end + // in a slash; the label shows exactly one. const excludedFolderLabel = `${excludedFolder}/`.replace(/\/+$/, "/") const forwardReferenceLabel = excludedCount === 1 ? "forward-ref" : "forward-refs" return `excludes ${excludedCount} ${forwardReferenceLabel} in ${excludedFolderLabel}` @@ -173,6 +176,8 @@ export const registerVaultOrientationPrompt = ({ const memoryFiles = memoryStore ? await memoryStore.listMemoryFiles({ vaultPath }, reqLogger) : [] + // Read once rather than through readEffectiveOrphanExcludeFolders, + // because the broken-link count below needs the same daily folder. const dailyNotesConfig = await readDailyNotesConfig( { vaultPath, @@ -185,7 +190,7 @@ export const registerVaultOrientationPrompt = ({ excludeFolders: resolveEffectiveOrphanExcludeFolders({ orphanExcludeFoldersOverride: config.orphanExcludeFoldersOverride, memoryDir: config.memoryDir, - dailyNotesFolder: dailyNotesConfig.folder, + dailyNotesFolder: dailyNotesConfig.dedicatedFolder, }), limit: ORIENTATION_ORPHAN_LIMIT + 1, }, @@ -194,7 +199,7 @@ export const registerVaultOrientationPrompt = ({ const hasMoreOrphans = orphanResults.length > ORIENTATION_ORPHAN_LIMIT const orphans = orphanResults.slice(0, ORIENTATION_ORPHAN_LIMIT) const brokenLinkResult = search.brokenLinkCount( - { dailyNotesFolder: dailyNotesConfig.folder }, + { dailyNotesFolder: dailyNotesConfig.dedicatedFolder }, reqLogger, ) const stats = search.vaultStats({}, reqLogger) 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 8c2edd28d..b26471b0f 100644 --- a/src/vault-mcp/mcp-core/tools/daily-note-tools.ts +++ b/src/vault-mcp/mcp-core/tools/daily-note-tools.ts @@ -18,12 +18,12 @@ 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. + 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 (a blank folder there means Obsidian's new-note folder from .obsidian/app.json, the vault root by default; either file counts as absent when unreadable), 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 -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). +When to use: When you need today's or a specific date's daily note. ${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: 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. @@ -32,6 +32,7 @@ Errors: - "invalid date" — use YYYY-MM-DD format - "… is not a calendar date" — the date has the YYYY-MM-DD form but names a day that doesn't exist, such as 2026-02-30; pass a real date - "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 +- "path traversal blocked" / "hidden path blocked" / "absolute path blocked" — the configured daily notes folder or format leads outside the vault or into a hidden folder; change it in Obsidian, or set DAILY_NOTES_FOLDER and DAILY_NOTES_FORMAT - "EACCES: …" or another filesystem error code — the daily note's file can't be read (permissions, a symbolic link loop); ask the vault's owner to fix it 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."}`, @@ -58,9 +59,12 @@ Returns: JSON with path (string — resolved vault-relative path), content (stri { vaultPath, date, - envSettings: { - folder: config.dailyNotesFolder, - format: config.dailyNotesFormat, + settings: { + source: "env", + envSettings: { + folder: config.dailyNotesFolder, + format: config.dailyNotesFormat, + }, }, }, reqLogger, diff --git a/src/vault-mcp/mcp-core/tools/search-tools.ts b/src/vault-mcp/mcp-core/tools/search-tools.ts index 5dce95936..d78b2f610 100644 --- a/src/vault-mcp/mcp-core/tools/search-tools.ts +++ b/src/vault-mcp/mcp-core/tools/search-tools.ts @@ -589,7 +589,7 @@ 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"). 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}. +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, unless the daily notes folder setting is the vault root or blank — 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: - "path must end in …" — add the .md or .canvas extension @@ -620,7 +620,7 @@ Errors: reqLogger, ) return search.getOutgoingLinks( - { path, dailyNotesFolder: dailyNotesConfig.folder }, + { path, dailyNotesFolder: dailyNotesConfig.dedicatedFolder }, reqLogger, ) }, @@ -641,14 +641,14 @@ Errors: : `the daily notes folder, "Templates", ${JSON.stringify(config.memoryDir)}` const orphanDefaultDescription = config.orphanExcludeFoldersOverride ? "With exclude_folders omitted, the server's configured list (the schema default) is used." - : 'With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else "Daily Notes" (also used when that file is unreadable).' + : 'With exclude_folders omitted, the defaults apply; the daily notes folder among them is re-read on each call: DAILY_NOTES_FOLDER, else .obsidian/daily-notes.json, else "Daily Notes" (also used when that file is unreadable). When that folder is the vault root or blank, no daily folder is excluded.' // An override list may not include the daily notes folder, so the hint only // accompanies the built-in defaults. const dailyNotesFolderHint = config.orphanExcludeFoldersOverride ? "" : whenToolEnabledText( "vault_get_daily_note", - " (vault_get_daily_note's path starts with the daily notes folder)", + " (vault_get_daily_note's path starts with the daily notes folder whenever the defaults exclude one)", ) registerTool( 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 5a50195d2..45fcd15a1 100644 --- a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts +++ b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts @@ -43,7 +43,7 @@ const describeProtectedPaths = (config: VaultConfig): string => { // loadConfig strips trailing slashes from override entries. return config.protectedPathsOverride.map((protectedPath) => protectedPath + "/").join(", ") } - return `${config.memoryDir}/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to ${DEFAULT_DAILY_NOTES_FOLDER}/)` + return `${config.memoryDir}/ and the daily notes folder (read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json, defaulting to ${DEFAULT_DAILY_NOTES_FOLDER}/; not protected when it is the vault root or blank)` } /** Sentences that point to other tools, each already "" when its tool is @@ -1108,7 +1108,8 @@ Errors: - "absolute path blocked" / "path traversal blocked" / "hidden path blocked" — use a vault-relative path with no hidden (dot-prefixed) file or folder in it - "concurrent write in progress" — another write to this note is in flight; retry - "note not found: …" — ${noteNotFoundVerifyRemedy}${trashMoveErrorEntries} -- "cannot delete …" (other than a protected path) — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry${trashConfigErrorEntry} +- "cannot delete …" (other than a protected path), "EACCES: …" or another filesystem error code — the note stays put; ask the vault owner to fix the cause (e.g. permissions), then retry +- "ENAMETOOLONG: …" — the path, or a name in it, is longer than the filesystem allows; check the path${trashConfigErrorEntry} - "cannot read daily notes config from .obsidian/daily-notes.json" — the file exists but is unreadable, so the daily notes folder to protect is unknown; ask the vault owner to repair it, or the server operator to set DAILY_NOTES_FOLDER or PROTECTED_PATHS, then retry Returns: ${deleteNoteReturns} Notes how many empty folders were pruned when any were.`, @@ -1211,7 +1212,7 @@ Example: vault_move_note({ old_path: "Inbox/Spec.md", new_path: "Projects/Spec.m Example: 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. When to use: Renaming a note or relocating it to a different folder while keeping the link graph intact. -Prefer this over vault_write_note + vault_delete_note, which would orphan every backlink.${bodyOrPropertiesEditSentence} Protected paths (${describeProtectedPaths(config)}) cannot be moved. +Prefer this over vault_write_note + vault_delete_note, which would orphan every backlink.${bodyOrPropertiesEditSentence} Protected paths cannot be moved: ${describeProtectedPaths(config)}. Parameters: - 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. @@ -1229,6 +1230,9 @@ Errors: - 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. - 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. - "move aborted: could not read …" / "move aborted: could not rewrite …" — the note, or a note linking to it, could not be read or has a properties block the move cannot keep; nothing was written; fix that note (for a properties block, the error says how), then retry. +- "move aborted: could not write to …" — new_path could not be created; nothing was written. Saving stages a temp file 41 bytes longer than new_path's name, so a long name fails; retry with one at least 41 bytes shorter. If that fails, the folder or disk can't be written; ask the vault's owner to fix it. +- "ENAMETOOLONG: …" — old_path or new_path, or a name in it, is longer than the filesystem allows; check the path. +- "EACCES: …" or another filesystem error code — a note or folder can't be read or written (permissions, a full or read-only disk); nothing was written; ask the vault's owner to fix it. Obsidian 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. diff --git a/src/vault-mcp/search/search-queries.ts b/src/vault-mcp/search/search-queries.ts index 9423a409d..ff241efd6 100644 --- a/src/vault-mcp/search/search-queries.ts +++ b/src/vault-mcp/search/search-queries.ts @@ -1462,9 +1462,10 @@ export const getBacklinks = ( * "file" for resolved non-markdown files (.canvas, .base, images, etc.), * defaulting to "note" for unresolved (broken) links. Accepts both * .md and .canvas paths — canvas file-node references appear as outgoing. - * When the caller passes the vault's daily notes folder (resolved fresh - * via readDailyNotesConfig), broken links under it are flagged - * daily_note_forward_ref — expected "create on click" navigation. */ + * When the caller passes the vault's daily notes folder (readDailyNotesConfig's + * dedicatedFolder), broken links under it are flagged + * daily_note_forward_ref — expected "create on click" navigation. Null, for a + * vault-root or blank setting, flags none. */ export const getOutgoingLinks = ( context: SearchQueryContext, params: { path: string; dailyNotesFolder?: string | null }, @@ -1585,10 +1586,11 @@ type BrokenLinkResult = { /** Counts unique broken link targets — links whose targets exist in * neither the notes table nor the non_md_files table. When the caller - * passes the vault's daily notes folder (resolved fresh via - * readDailyNotesConfig), broken links under it are excluded — they are + * passes the vault's daily notes folder (readDailyNotesConfig's + * dedicatedFolder), broken links under it are excluded — they are * forward-references (intentional "create on click" navigation), not - * genuinely broken. Returns the count plus exclusion metadata so + * genuinely broken. Null, for a vault-root or blank setting, excludes none. + * Returns the count plus exclusion metadata so * callers can communicate what was filtered. */ export const brokenLinkCount = ( context: SearchQueryContext, diff --git a/src/vault-mcp/vault-operations/__tests__/daily-notes.test.ts b/src/vault-mcp/vault-operations/__tests__/daily-notes.test.ts index 93e1cac13..580c8c393 100644 --- a/src/vault-mcp/vault-operations/__tests__/daily-notes.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/daily-notes.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os" import { logger } from "../../../logger.js" import { readDailyNotesConfig, - readDailyNotesFileConfig, + readProtectedDailyNotesFolder, getDailyNotePath, getDailyNote, } from "../daily-notes.js" @@ -33,7 +33,11 @@ const jsonParseFailureMessage = (malformedJson: string): string => { throw new Error("expected JSON.parse to reject the malformed input") } -const FALLBACK_CONFIG = { folder: "Daily Notes", format: "YYYY-MM-DD" } +const FALLBACK_CONFIG = { + folder: "Daily Notes", + format: "YYYY-MM-DD", + dedicatedFolder: "Daily Notes", +} // ── readDailyNotesConfig ───────────────────────────────────────── @@ -44,7 +48,11 @@ describe("readDailyNotesConfig", () => { const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) - expect(config).toEqual({ folder: "Journal", format: "YYYY-MM-DD-dddd" }) + expect(config).toEqual({ + folder: "Journal", + format: "YYYY-MM-DD-dddd", + dedicatedFolder: "Journal", + }) }) it("falls back to defaults when file is missing", async () => { @@ -89,25 +97,158 @@ describe("readDailyNotesConfig", () => { expect(rootWarnSpy).not.toHaveBeenCalled() }) - it("uses default folder when config has empty folder string", async () => { + it("reads Obsidian's vault-root setting, a lone slash, as the vault root", async () => { const vaultDir = await createVault() - await writeDailyNotesConfig(vaultDir, { folder: "", format: "YYYY-MM-DD" }) + await writeDailyNotesConfig(vaultDir, { folder: "/", format: "YYYY-MM-DD" }) const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) - expect(config).toEqual(FALLBACK_CONFIG) + expect(config).toEqual({ folder: "", format: "YYYY-MM-DD", dedicatedFolder: null }) }) - it("reads a file holding the JSON literal null as no settings, without a warn", async () => { + it.each([ + { label: "leading and trailing slashes", setting: "/Journal/", expected: "Journal" }, + { label: "repeated slashes", setting: "Journal//2026", expected: "Journal/2026" }, + { label: "backslashes", setting: "Journal\\2026", expected: "Journal/2026" }, + { label: "no-break spaces", setting: "Daily\u00A0Notes\u202FLog", expected: "Daily Notes Log" }, + // "e" plus a combining acute accent, composed into "é" + { label: "decomposed accents", setting: "Journe\u0301es", expected: "Journ\u00E9es" }, + ])("normalizes $label in the folder the way Obsidian does", async ({ setting, expected }) => { const vaultDir = await createVault() - await writeDailyNotesConfig(vaultDir, null) - const warnSpy = vi.spyOn(logger, "warn") - onTestFinished(() => warnSpy.mockRestore()) + await writeDailyNotesConfig(vaultDir, { folder: setting, format: "YYYY-MM-DD" }) const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) - expect(config).toEqual(FALLBACK_CONFIG) - expect(warnSpy).not.toHaveBeenCalled() + expect(config).toEqual({ folder: expected, format: "YYYY-MM-DD", dedicatedFolder: expected }) + }) + + describe("blank folder in daily-notes.json", () => { + const writeAppConfig = async (vaultDir: string, appConfig: unknown): Promise => { + await writeFile(join(vaultDir, ".obsidian", "app.json"), JSON.stringify(appConfig), "utf8") + } + + it("uses the vault root when app.json does not exist, as Obsidian's default", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "", format: "YYYY-MM-DD" }) + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + + expect(config).toEqual({ folder: "", format: "YYYY-MM-DD", dedicatedFolder: null }) + // The unreadable-app.json fallback also gives the root, but with a warn + expect(warnSpy).not.toHaveBeenCalled() + }) + + it("uses the folder app.json sets for new notes, without treating it as daily-only", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { format: "DD-MM-YYYY" }) + await writeAppConfig(vaultDir, { newFileLocation: "folder", newFileFolderPath: "Inbox/" }) + + const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + + expect(config).toEqual({ folder: "Inbox", format: "DD-MM-YYYY", dedicatedFolder: null }) + }) + + it("uses the vault root when new notes go next to the current file", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "" }) + await writeAppConfig(vaultDir, { newFileLocation: "current", newFileFolderPath: "Inbox" }) + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + + expect(config).toEqual({ folder: "", format: "YYYY-MM-DD", dedicatedFolder: null }) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it.each([ + { + label: "a non-string folder path", + appConfig: { newFileLocation: "folder", newFileFolderPath: 42 }, + }, + { label: "no folder path", appConfig: { newFileLocation: "folder" } }, + { label: "the JSON literal null", appConfig: null }, + ])( + "uses the vault root, without a warn, for an app.json with $label", + async ({ appConfig }) => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "" }) + await writeAppConfig(vaultDir, appConfig) + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + + expect(config).toEqual({ folder: "", format: "YYYY-MM-DD", dedicatedFolder: null }) + expect(warnSpy).not.toHaveBeenCalled() + }, + ) + + it("uses the vault root, without a warn, when app.json is a directory", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "" }) + await mkdir(join(vaultDir, ".obsidian", "app.json")) + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + + expect(config).toEqual({ folder: "", format: "YYYY-MM-DD", dedicatedFolder: null }) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it("reads a file holding the JSON literal null as a blank folder, without a warn", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, null) + await writeAppConfig(vaultDir, { newFileLocation: "folder", newFileFolderPath: "Inbox" }) + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) + + expect(config).toEqual({ folder: "Inbox", format: "YYYY-MM-DD", dedicatedFolder: null }) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it("uses the vault root on a malformed app.json and warns on the caller's logger", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "" }) + const malformedJson = "not valid json{{{" + await writeFile(join(vaultDir, ".obsidian", "app.json"), malformedJson, "utf8") + const requestLogger = logger.child({ requestId: "request-1" }) + const requestWarnSpy = vi.spyOn(requestLogger, "warn") + + const config = await readDailyNotesConfig({ vaultPath: vaultDir }, requestLogger) + + expect(config).toEqual({ folder: "", format: "YYYY-MM-DD", dedicatedFolder: null }) + expect(requestWarnSpy).toHaveBeenCalledTimes(1) + expect(requestWarnSpy).toHaveBeenCalledWith( + "cannot read new note location, using the vault root", + { error: `[SyntaxError]: ${jsonParseFailureMessage(malformedJson)}` }, + ) + }) + + it("lets a folder override win without reading app.json", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "", format: "DD-MM-YYYY" }) + await writeFile(join(vaultDir, ".obsidian", "app.json"), "not valid json{{{", "utf8") + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) + + const config = await readDailyNotesConfig( + { vaultPath: vaultDir, envSettings: { folder: "Override Folder" } }, + logger, + ) + + expect(config).toEqual({ + folder: "Override Folder", + format: "DD-MM-YYYY", + dedicatedFolder: "Override Folder", + }) + expect(warnSpy).not.toHaveBeenCalled() + }) }) it("uses default format when config has empty format string", async () => { @@ -116,7 +257,7 @@ describe("readDailyNotesConfig", () => { const config = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) - expect(config).toEqual({ folder: "Journal", format: "YYYY-MM-DD" }) + expect(config).toEqual({ folder: "Journal", format: "YYYY-MM-DD", dedicatedFolder: "Journal" }) }) it("follows a folder and format change between two reads", async () => { @@ -124,11 +265,11 @@ describe("readDailyNotesConfig", () => { await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "YYYY-MM-DD" }) const first = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) - expect(first).toEqual({ folder: "Journal", format: "YYYY-MM-DD" }) + expect(first).toEqual({ folder: "Journal", format: "YYYY-MM-DD", dedicatedFolder: "Journal" }) await writeDailyNotesConfig(vaultDir, { folder: "Changed", format: "DD-MM-YYYY" }) const second = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) - expect(second).toEqual({ folder: "Changed", format: "DD-MM-YYYY" }) + expect(second).toEqual({ folder: "Changed", format: "DD-MM-YYYY", dedicatedFolder: "Changed" }) }) it("picks up a config file that appears after an earlier read", async () => { @@ -139,7 +280,11 @@ describe("readDailyNotesConfig", () => { await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "DD-MM-YYYY" }) const afterFileExists = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) - expect(afterFileExists).toEqual({ folder: "Journal", format: "DD-MM-YYYY" }) + expect(afterFileExists).toEqual({ + folder: "Journal", + format: "DD-MM-YYYY", + dedicatedFolder: "Journal", + }) }) it("picks up a repaired config file after a malformed read", async () => { @@ -154,7 +299,11 @@ describe("readDailyNotesConfig", () => { await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "DD-MM-YYYY" }) const afterFix = await readDailyNotesConfig({ vaultPath: vaultDir }, logger) - expect(afterFix).toEqual({ folder: "Journal", format: "DD-MM-YYYY" }) + expect(afterFix).toEqual({ + folder: "Journal", + format: "DD-MM-YYYY", + dedicatedFolder: "Journal", + }) expect(warnSpy).toHaveBeenCalledTimes(1) }) @@ -168,7 +317,11 @@ describe("readDailyNotesConfig", () => { logger, ) - expect(config).toEqual({ folder: "Override Folder", format: "DD-MM-YYYY" }) + expect(config).toEqual({ + folder: "Override Folder", + format: "DD-MM-YYYY", + dedicatedFolder: "Override Folder", + }) }) it("format-only override wins over the file's format, file keeps folder", async () => { @@ -180,7 +333,11 @@ describe("readDailyNotesConfig", () => { logger, ) - expect(config).toEqual({ folder: "Journal", format: "YYYY_MM_DD" }) + expect(config).toEqual({ + folder: "Journal", + format: "YYYY_MM_DD", + dedicatedFolder: "Journal", + }) }) it("both overrides win over a conflicting file", async () => { @@ -192,7 +349,11 @@ describe("readDailyNotesConfig", () => { logger, ) - expect(config).toEqual({ folder: "Override Folder", format: "YYYY_MM_DD" }) + expect(config).toEqual({ + folder: "Override Folder", + format: "YYYY_MM_DD", + dedicatedFolder: "Override Folder", + }) }) it("both overrides skip the file, so a malformed one is never read", async () => { @@ -206,7 +367,11 @@ describe("readDailyNotesConfig", () => { logger, ) - expect(config).toEqual({ folder: "Override Folder", format: "YYYY_MM_DD" }) + expect(config).toEqual({ + folder: "Override Folder", + format: "YYYY_MM_DD", + dedicatedFolder: "Override Folder", + }) expect(warnSpy).not.toHaveBeenCalled() }) @@ -218,9 +383,29 @@ describe("readDailyNotesConfig", () => { logger, ) - expect(config).toEqual({ folder: "Override Folder", format: "YYYY_MM_DD" }) + expect(config).toEqual({ + folder: "Override Folder", + format: "YYYY_MM_DD", + dedicatedFolder: "Override Folder", + }) }) + it.each([ + { label: "alone", envSettings: { folder: "" } }, + { label: "with a format", envSettings: { folder: "", format: "DD-MM-YYYY" } }, + ])( + "treats a vault-root folder override ($label) as the shared root", + async ({ envSettings }) => { + const vaultDir = await createVault() + // A named folder on disk, so falling through to the file would show + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "DD-MM-YYYY" }) + + const config = await readDailyNotesConfig({ vaultPath: vaultDir, envSettings }, logger) + + expect(config).toEqual({ folder: "", format: "DD-MM-YYYY", dedicatedFolder: null }) + }, + ) + it("folder-only override without a config file falls back to the default format", async () => { const vaultDir = await createVault() @@ -229,53 +414,67 @@ describe("readDailyNotesConfig", () => { logger, ) - expect(config).toEqual({ folder: "Override Folder", format: "YYYY-MM-DD" }) + expect(config).toEqual({ + folder: "Override Folder", + format: "YYYY-MM-DD", + dedicatedFolder: "Override Folder", + }) }) }) }) -// ── readDailyNotesFileConfig ───────────────────────────────────── +// ── readProtectedDailyNotesFolder ──────────────────────────────── -describe("readDailyNotesFileConfig", () => { - it("reads folder and format from .obsidian/daily-notes.json", async () => { +describe("readProtectedDailyNotesFolder", () => { + it("reads the folder from .obsidian/daily-notes.json", async () => { const vaultDir = await createVault() - await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "DD-MM-YYYY" }) + await writeDailyNotesConfig(vaultDir, { folder: "Journal/", format: "DD-MM-YYYY" }) - const config = await readDailyNotesFileConfig(vaultDir, logger) + const folder = await readProtectedDailyNotesFolder(vaultDir, logger) - expect(config).toEqual({ folder: "Journal", format: "DD-MM-YYYY" }) + expect(folder).toBe("Journal") }) - it("falls back to defaults when the file is missing", async () => { + it("falls back to Daily Notes when the file is missing", async () => { const vaultDir = await createVault() const warnSpy = vi.spyOn(logger, "warn") onTestFinished(() => warnSpy.mockRestore()) - const config = await readDailyNotesFileConfig(vaultDir, logger) + const folder = await readProtectedDailyNotesFolder(vaultDir, logger) - expect(config).toEqual(FALLBACK_CONFIG) + expect(folder).toBe("Daily Notes") expect(warnSpy).not.toHaveBeenCalled() }) - it("falls back to defaults when .obsidian is a file rather than a folder", async () => { + it("falls back to Daily Notes when .obsidian is a file rather than a folder", async () => { const vaultDir = await mkdtemp(join(tmpdir(), "daily-notes-test-")) onTestFinished(async () => rm(vaultDir, { recursive: true })) await writeFile(join(vaultDir, ".obsidian"), "not a folder", "utf8") - const config = await readDailyNotesFileConfig(vaultDir, logger) + const folder = await readProtectedDailyNotesFolder(vaultDir, logger) - expect(config).toEqual(FALLBACK_CONFIG) + expect(folder).toBe("Daily Notes") }) - it("reads a file holding the JSON literal null as no settings, not as unreadable", async () => { + it.each([ + { label: "the vault root", config: { folder: "/" } }, + { label: "a blank folder (Obsidian's new-note location)", config: { folder: "" } }, + { label: "the JSON literal null (a blank folder)", config: null }, + ])("protects no folder for $label", async ({ config }) => { const vaultDir = await createVault() - await writeDailyNotesConfig(vaultDir, null) + await writeDailyNotesConfig(vaultDir, config) + // A new-note folder in app.json must not become the protected folder + await writeFile( + join(vaultDir, ".obsidian", "app.json"), + JSON.stringify({ newFileLocation: "folder", newFileFolderPath: "Inbox" }), + "utf8", + ) const warnSpy = vi.spyOn(logger, "warn") onTestFinished(() => warnSpy.mockRestore()) - const config = await readDailyNotesFileConfig(vaultDir, logger) + const folder = await readProtectedDailyNotesFolder(vaultDir, logger) - expect(config).toEqual(FALLBACK_CONFIG) + expect(folder).toBeNull() expect(warnSpy).not.toHaveBeenCalled() }) @@ -289,7 +488,7 @@ describe("readDailyNotesFileConfig", () => { const rootWarnSpy = vi.spyOn(logger, "warn") onTestFinished(() => rootWarnSpy.mockRestore()) - await expect(readDailyNotesFileConfig(vaultDir, requestLogger)).rejects.toThrow( + await expect(readProtectedDailyNotesFolder(vaultDir, requestLogger)).rejects.toThrow( new Error("cannot read daily notes config from .obsidian/daily-notes.json"), ) @@ -306,7 +505,7 @@ describe("readDailyNotesFileConfig", () => { const warnSpy = vi.spyOn(logger, "warn") onTestFinished(() => warnSpy.mockRestore()) - await expect(readDailyNotesFileConfig(vaultDir, logger)).rejects.toThrow( + await expect(readProtectedDailyNotesFolder(vaultDir, logger)).rejects.toThrow( new Error("cannot read daily notes config from .obsidian/daily-notes.json"), ) @@ -320,12 +519,12 @@ describe("readDailyNotesFileConfig", () => { const vaultDir = await createVault() await writeDailyNotesConfig(vaultDir, { folder: "Journal" }) - const first = await readDailyNotesFileConfig(vaultDir, logger) - expect(first).toEqual({ folder: "Journal", format: "YYYY-MM-DD" }) + const first = await readProtectedDailyNotesFolder(vaultDir, logger) + expect(first).toBe("Journal") await writeDailyNotesConfig(vaultDir, { folder: "Diary" }) - const second = await readDailyNotesFileConfig(vaultDir, logger) - expect(second).toEqual({ folder: "Diary", format: "YYYY-MM-DD" }) + const second = await readProtectedDailyNotesFolder(vaultDir, logger) + expect(second).toBe("Diary") }) }) @@ -349,6 +548,44 @@ describe("getDailyNotePath", () => { expect(path).toBe("Journal/13-05-2026.md") }) + it("resolves a path at the vault root when daily notes live there", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "/", format: "DD-MM-YYYY" }) + + const path = await getDailyNotePath({ vaultPath: vaultDir, date: "2026-05-13" }, logger) + + expect(path).toBe("13-05-2026.md") + }) + + it("trims spaces the format leaves at either end of the filename, as Obsidian does", async () => { + const vaultDir = await createVault() + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: " YYYY-MM-DD " }) + + const path = await getDailyNotePath({ vaultPath: vaultDir, date: "2026-05-13" }, logger) + + expect(path).toBe("Journal/2026-05-13.md") + }) + + it("uses a resolved config as is, reading no config file", async () => { + const vaultDir = await createVault() + // A different folder and format on disk, so a file read would change the path + await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "YYYY-MM-DD" }) + + const path = await getDailyNotePath( + { + vaultPath: vaultDir, + date: "2026-05-13", + settings: { + source: "resolved", + config: { folder: "", format: "DD-MM-YYYY", dedicatedFolder: null }, + }, + }, + logger, + ) + + expect(path).toBe("13-05-2026.md") + }) + it("resolves with env overrides winning over the config file", async () => { const vaultDir = await createVault() await writeDailyNotesConfig(vaultDir, { folder: "Journal", format: "YYYY-MM-DD" }) @@ -357,7 +594,10 @@ describe("getDailyNotePath", () => { { vaultPath: vaultDir, date: "2026-05-13", - envSettings: { folder: "Override Folder", format: "DD-MM-YYYY" }, + settings: { + source: "env", + envSettings: { folder: "Override Folder", format: "DD-MM-YYYY" }, + }, }, logger, ) diff --git a/src/vault-mcp/vault-operations/__tests__/vault-folder-config.test.ts b/src/vault-mcp/vault-operations/__tests__/vault-folder-config.test.ts index 8889d9335..8275bee28 100644 --- a/src/vault-mcp/vault-operations/__tests__/vault-folder-config.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/vault-folder-config.test.ts @@ -58,6 +58,20 @@ describe("resolveEffectiveProtectedPaths", () => { ).toEqual(["About Me"]) }) + it.each([ + { label: "daily notes in the vault root", settings: '{"folder":"/"}' }, + { label: "daily notes in Obsidian's new-note folder", settings: '{"folder":""}' }, + ])("protects only memory for $label", async ({ settings }) => { + const vaultPath = await createVault(settings) + await writeFile( + join(vaultPath, ".obsidian/app.json"), + '{"newFileLocation":"folder","newFileFolderPath":"Inbox"}', + ) + expect( + await resolveEffectiveProtectedPaths({ config: loadConfig({}), vaultPath }, logger), + ).toEqual(["About Me"]) + }) + it("preserves spaces in a nonblank file-configured daily folder", async () => { const vaultPath = await createVault('{"folder":" Journal "}') expect( @@ -100,6 +114,14 @@ describe("resolveEffectiveProtectedPaths", () => { expect(requestLogger.warn).not.toHaveBeenCalled() }) + it("protects only memory for DAILY_NOTES_FOLDER=/ without reading the file", async () => { + const vaultPath = await createVault('{"folder":"Journal"}') + const config = loadConfig({ DAILY_NOTES_FOLDER: "/" }) + expect(await resolveEffectiveProtectedPaths({ config, vaultPath }, logger)).toEqual([ + "About Me", + ]) + }) + it("reads the file folder when only DAILY_NOTES_FORMAT is set", async () => { const vaultPath = await createVault('{"folder":"Journal"}') const config = loadConfig({ DAILY_NOTES_FORMAT: "DD-MM-YYYY" }) @@ -143,6 +165,16 @@ describe("resolveEffectiveOrphanExcludeFolders", () => { ).toEqual(["Templates", "About Me"]) }) + it("omits the daily folder when daily notes share a folder with other notes", () => { + expect( + resolveEffectiveOrphanExcludeFolders({ + orphanExcludeFoldersOverride: null, + memoryDir: "About Me", + dailyNotesFolder: null, + }), + ).toEqual(["Templates", "About Me"]) + }) + it("preserves an explicit empty list", () => { expect( resolveEffectiveOrphanExcludeFolders({ @@ -186,8 +218,25 @@ describe("readEffectiveOrphanExcludeFolders", () => { env: {}, expected: ["Templates", "About Me"], }, + { + label: "vault-root file folder", + settings: '{"folder":"/"}', + env: {}, + expected: ["Templates", "About Me"], + }, + { + label: "empty file folder, which follows the new-note folder", + settings: '{"folder":""}', + env: {}, + expected: ["Templates", "About Me"], + }, ])("uses $label", async ({ settings, env, expected }) => { const vaultPath = await createVault(settings) + // The new-note folder is where a blank daily folder resolves; it must not be excluded + await writeFile( + join(vaultPath, ".obsidian/app.json"), + '{"newFileLocation":"folder","newFileFolderPath":"Inbox"}', + ) expect( await readEffectiveOrphanExcludeFolders({ config: loadConfig(env), vaultPath }, logger), ).toEqual(expected) @@ -199,6 +248,11 @@ describe("readEffectiveOrphanExcludeFolders", () => { env: { DAILY_NOTES_FOLDER: "Env/Journal" }, expected: ["Env/Journal", "Templates", "About Me"], }, + { + label: "vault-root daily folder override", + env: { DAILY_NOTES_FOLDER: "/" }, + expected: ["Templates", "About Me"], + }, { label: "explicit orphan override", env: { ORPHAN_EXCLUDE_FOLDERS: "Archive,Scratch" }, diff --git a/src/vault-mcp/vault-operations/daily-notes.ts b/src/vault-mcp/vault-operations/daily-notes.ts index 0694106ce..3da840192 100644 --- a/src/vault-mcp/vault-operations/daily-notes.ts +++ b/src/vault-mcp/vault-operations/daily-notes.ts @@ -5,45 +5,103 @@ import type { Logger } from "../../logger.js" import { vaultFs } from "./vault-filesystem.js" import { momentToLuxonFormat, findUnsupportedTokens } from "../obsidian-markdown/moment-format.js" import { describeError } from "../../utils/describe-error.js" -import { isMissingPathError } from "../../utils/fs.js" +import { isMissingPathError, readFileOrNull } from "../../utils/fs.js" import { isRecord } from "../../utils/is-record.js" // ── Config reading ────────────────────────────────────────────── // -// Three readers, each wrapping readDailyNotesFile: -// readDailyNotesFileConfig — strict: throws on an unreadable file (for the delete/move guard) -// readDailyNotesFileConfigOrFallback — tolerant: warns + falls back (for reads and prompts) -// readDailyNotesConfig — public: env → file → fallbacks, uses the tolerant reader +// File readers: +// readDailyNotesFile — parses .obsidian/daily-notes.json; throws on an unreadable file +// readNewNoteFolder — Obsidian's new-note folder from .obsidian/app.json; warns + falls back +// Their callers: +// readProtectedDailyNotesFolder — strict: rethrows an unreadable daily-notes.json (for the delete/move guard) +// readDailyNotesFileOrFallback — tolerant: warns + falls back (for reads and prompts) +// readDailyNotesConfig — public: env → file → fallbacks, through the tolerant reader type DailyNotesConfig = { + /** Where daily notes are read from: vault-relative, with no leading or + * trailing slash; "" is the vault root. */ folder: string format: string + /** The folder when it holds daily notes apart from other notes, for the + * delete guard, orphan exclusion and forward-reference counts. Null when + * the setting is the vault root or blank (Obsidian's new-note folder), + * since treating those as daily-only would cover every other note there. */ + dedicatedFolder: string | null +} + +/** The folder setting in daily-notes.json: + * - `no-file`: no usable file. It does not exist, or the tolerant reader + * could not read or parse it. + * - `blank`: the file exists with a missing or empty folder, so Obsidian + * puts daily notes in its location for new notes. + * - `named`: the normalized folder; "" is the vault root. */ +type DailyNotesFolderSetting = + { kind: "no-file" } | { kind: "blank" } | { kind: "named"; folder: string } + +type DailyNotesFileSettings = { + folderSetting: DailyNotesFolderSetting + format: string } /** Per-field settings from the DAILY_NOTES_FOLDER / DAILY_NOTES_FORMAT env - * vars; each set field takes precedence over .obsidian/daily-notes.json. */ + * vars; each set field takes precedence over .obsidian/daily-notes.json. + * A folder of "" is the vault root. */ export type DailyNotesEnvSettings = { folder?: string | undefined format?: string | undefined } +/** Where a daily note's folder and format come from: + * - `env`: the env settings, with unset fields read from the vault's config. + * - `resolved`: a config readDailyNotesConfig already returned, used as is, + * so a caller that needs the config itself reads the files only once. */ +type DailyNotesSettings = + | { source: "env"; envSettings: DailyNotesEnvSettings } + | { source: "resolved"; config: DailyNotesConfig } + +// The server's own choice for a vault with no daily-notes.json. Obsidian then +// uses its location for new notes, which is not a sensible folder to assume. export const DEFAULT_DAILY_NOTES_FOLDER = "Daily Notes" -// The format matches Obsidian's default; the folder is this server's own -// choice — Obsidian with no configured location creates dailies in the -// vault root, which is not a sensible folder for the server to assume. -const FALLBACK_CONFIG: DailyNotesConfig = { - folder: DEFAULT_DAILY_NOTES_FOLDER, - format: "YYYY-MM-DD", +// Obsidian's own default format. +const DEFAULT_DAILY_NOTES_FORMAT = "YYYY-MM-DD" + +/** The settings when daily-notes.json is missing, and when the tolerant + * reader cannot read it. */ +const FALLBACK_FILE_SETTINGS: DailyNotesFileSettings = { + folderSetting: { kind: "no-file" }, + format: DEFAULT_DAILY_NOTES_FORMAT, } -/** Reads and parses .obsidian/daily-notes.json. Returns the fallbacks when - * no file exists there, and for each field the file does not set; any - * other failure propagates. */ -const readDailyNotesFile = async (vaultPath: string): Promise => { +/** Matches each run of forward or back slashes in a folder setting. */ +const SLASH_RUN_RE = /[\\/]+/g + +/** Matches the no-break space (U+00A0) and the narrow no-break space + * (U+202F), which Obsidian turns into plain spaces in a path. */ +const NO_BREAK_SPACE_RE = /[\u00A0\u202F]/g + +/** Normalizes a folder setting the way Obsidian's normalizePath does before + * using it: + * - Slash runs (either direction) become one "/", and leading and trailing + * slashes are dropped. Obsidian saves the vault root as "/", which becomes "". + * - No-break spaces become plain spaces, and the result is NFC-normalized. */ +const normalizeFolderSetting = (folderSetting: string): string => { + return folderSetting + .replace(SLASH_RUN_RE, "/") + .replace(/^\/|\/$/g, "") + .replace(NO_BREAK_SPACE_RE, " ") + .normalize("NFC") +} + +/** Reads and parses .obsidian/daily-notes.json. A missing file gives the + * `no-file` setting; any other failure propagates. */ +const readDailyNotesFile = async (vaultPath: string): Promise => { try { // Read on every call, so a folder or format change in Obsidian applies - // to the next operation rather than after a restart. + // to the next operation rather than after a restart. readFile, not + // readFileOrNull: that helper returns null for a folder at this path, + // which the delete guard must refuse as unreadable, not treat as missing. const configFileContent = await readFile( join(vaultPath, ".obsidian", "daily-notes.json"), "utf8", @@ -51,34 +109,99 @@ const readDailyNotesFile = async (vaultPath: string): Promise const parsedConfig: unknown = JSON.parse(configFileContent) // Valid JSON can be `null` or a bare value, which has no keys to read. const settings = isRecord(parsedConfig) ? parsedConfig : {} + // A whitespace-only folder stays `named`: reads use it as written, while + // the delete guard and orphan exclusion treat it as no folder. + const folderSetting: DailyNotesFolderSetting = + typeof settings.folder === "string" && settings.folder.length > 0 + ? { kind: "named", folder: normalizeFolderSetting(settings.folder) } + : { kind: "blank" } return { - folder: - typeof settings.folder === "string" && settings.folder.length > 0 - ? settings.folder - : FALLBACK_CONFIG.folder, + folderSetting, format: typeof settings.format === "string" && settings.format.length > 0 ? settings.format - : FALLBACK_CONFIG.format, + : DEFAULT_DAILY_NOTES_FORMAT, } } catch (error) { // No file, or no .obsidian/ folder at all: on a fresh remote deploy the // config can arrive after boot, so the fallbacks stand in until it does. - if (isMissingPathError(error)) return { ...FALLBACK_CONFIG } + if (isMissingPathError(error)) return FALLBACK_FILE_SETTINGS throw error } } -/** Reads the folder and filename format from .obsidian/daily-notes.json, for - * the protected-path guard. - * - Returns the fallbacks ("Daily Notes", "YYYY-MM-DD") when the file is missing. +/** Where Obsidian's "Default location for new notes" setting puts a daily + * note, read from `newFileLocation` and `newFileFolderPath` in + * .obsidian/app.json. "" is the vault root. + * - "In the folder specified below": that folder. + * - "Vault folder": the root. + * - "Same folder as current file": the root. Obsidian creates a daily note + * without reference to an open note, so it has no folder to follow. + * - A missing or unreadable app.json: the root, Obsidian's default. + * - Unlike Obsidian, a set folder that no longer exists is not swapped for + * the root; the daily note then reads as missing. */ +const readNewNoteFolder = async (vaultPath: string, logger: Logger): Promise => { + try { + const appConfigContent = await readFileOrNull(join(vaultPath, ".obsidian", "app.json")) + + if (appConfigContent === null) return "" + + const parsedConfig: unknown = JSON.parse(appConfigContent) + const settings = isRecord(parsedConfig) ? parsedConfig : {} + const { newFileLocation, newFileFolderPath } = settings + + if (newFileLocation === "folder" && typeof newFileFolderPath === "string") { + return normalizeFolderSetting(newFileFolderPath) + } + return "" + } catch (error) { + // The vault root is a safe default: this folder only locates daily notes, + // and the delete and move guard never protects a blank folder setting, so + // a wrong folder can only make a daily note read as missing. + logger.warn("cannot read new note location, using the vault root", { + error: describeError(error), + }) + return "" + } +} + +/** The folder daily notes are read from; a blank setting follows Obsidian to + * its new-note folder. */ +const resolveDailyNotesFolder = async ( + { folderSetting, vaultPath }: { folderSetting: DailyNotesFolderSetting; vaultPath: string }, + logger: Logger, +): Promise => { + if (folderSetting.kind === "no-file") return DEFAULT_DAILY_NOTES_FOLDER + if (folderSetting.kind === "blank") return readNewNoteFolder(vaultPath, logger) + return folderSetting.folder +} + +/** The folder that holds daily notes apart from other notes, or null. Unlike + * resolveDailyNotesFolder, a blank or vault-root setting gives null, because + * that folder also holds other new notes. */ +const resolveDedicatedFolder = (folderSetting: DailyNotesFolderSetting): string | null => { + if (folderSetting.kind === "no-file") return DEFAULT_DAILY_NOTES_FOLDER + if (folderSetting.kind === "blank") return null + + // A vault-root setting ("/") normalizes to "". + const isVaultRoot = folderSetting.folder === "" + + if (isVaultRoot) return null + return folderSetting.folder +} + +/** Reads the daily notes folder the delete and move guard protects, from + * .obsidian/daily-notes.json. + * - Returns "Daily Notes" when the file is missing, and null when its + * folder is the vault root or blank. * - Throws when the file exists but cannot be read or parsed. */ -export const readDailyNotesFileConfig = async ( +export const readProtectedDailyNotesFolder = async ( vaultPath: string, logger: Logger, -): Promise => { +): Promise => { try { - return await readDailyNotesFile(vaultPath) + const { folderSetting } = await readDailyNotesFile(vaultPath) + return resolveDedicatedFolder(folderSetting) } catch (error) { // A fallback here would protect "Daily Notes" while the user's folder is // whatever the broken file says. The warn carries the raw cause; only the @@ -90,28 +213,32 @@ export const readDailyNotesFileConfig = async ( } } -/** The file config, or the fallbacks with a `warn` when the file exists but - * cannot be read or parsed — for reads and prompts, where a wrong folder - * costs a missed note, not a deleted one. */ -const readDailyNotesFileConfigOrFallback = async ( +/** The file settings, or the fallbacks with a `warn` when the file exists but + * cannot be read or parsed. Only reads and prompts use it, where a wrong + * folder can only make a daily note read as missing; the delete and move + * guard throws instead (readProtectedDailyNotesFolder). */ +const readDailyNotesFileOrFallback = async ( vaultPath: string, logger: Logger, -): Promise => { +): Promise => { try { return await readDailyNotesFile(vaultPath) } catch (error) { logger.warn("cannot read daily notes config, using defaults", { error: describeError(error), }) - return { ...FALLBACK_CONFIG } + return FALLBACK_FILE_SETTINGS } } /** Resolves the vault's daily note folder and filename format with * per-field precedence: env setting → .obsidian/daily-notes.json → - * the fallbacks ("Daily Notes", "YYYY-MM-DD"). When both fields are - * set via env the config file is not read at all. A file that exists but - * cannot be read or parsed logs a `warn` and counts as absent. */ + * the fallbacks ("Daily Notes", "YYYY-MM-DD"). + * - A blank folder in daily-notes.json means Obsidian's location for new + * notes, read from .obsidian/app.json. + * - When both fields are set via env, no config file is read. + * - A daily-notes.json that exists but cannot be read or parsed logs a + * `warn` and counts as absent. */ export const readDailyNotesConfig = async ( params: { vaultPath: string @@ -120,17 +247,32 @@ export const readDailyNotesConfig = async ( logger: Logger, ): Promise => { const { vaultPath, envSettings } = params + const envFolder = envSettings?.folder + const envFormat = envSettings?.format + + // DAILY_NOTES_FOLDER="/" arrives as "" (the vault root). That is still a set + // folder, so test for undefined; its dedicatedFolder is null, as for a + // vault-root setting in daily-notes.json. + const hasEnvFolder = envFolder !== undefined + const envDedicatedFolder = envFolder || null - // Both fields set via env — the file can't contribute anything, skip I/O. - if (envSettings?.folder && envSettings.format) { - return { folder: envSettings.folder, format: envSettings.format } + // Both fields set — the files can't contribute anything, skip I/O. + if (hasEnvFolder && envFormat) { + return { folder: envFolder, format: envFormat, dedicatedFolder: envDedicatedFolder } } - const fileConfig = await readDailyNotesFileConfigOrFallback(vaultPath, logger) - return { - folder: envSettings?.folder ?? fileConfig.folder, - format: envSettings?.format ?? fileConfig.format, + const { folderSetting, format: fileFormat } = await readDailyNotesFileOrFallback( + vaultPath, + logger, + ) + const format = envFormat ?? fileFormat + + if (hasEnvFolder) { + return { folder: envFolder, format, dedicatedFolder: envDedicatedFolder } } + + const folder = await resolveDailyNotesFolder({ folderSetting, vaultPath }, logger) + return { folder, format, dedicatedFolder: resolveDedicatedFolder(folderSetting) } } // ── Path resolution + read ────────────────────────────────────── @@ -145,19 +287,21 @@ export const describeNonCalendarDate = (date: string): string => { return `"${date}" is not a calendar date. Pass a real date in YYYY-MM-DD format.` } -/** Resolves a date to a vault-relative daily note path using the env - * settings, the vault's .obsidian/daily-notes.json config, and - * the fallbacks — in that per-field precedence order. */ +/** Resolves a date to a vault-relative daily note path. Without settings, + * the folder and format come from the vault's config and the fallbacks. */ export const getDailyNotePath = async ( params: { vaultPath: string date?: string | undefined - envSettings?: DailyNotesEnvSettings | undefined + settings?: DailyNotesSettings | undefined }, logger: Logger, ): Promise => { - const { vaultPath, date, envSettings } = params - const config = await readDailyNotesConfig({ vaultPath, envSettings }, logger) + const { vaultPath, date, settings } = params + const config = + settings?.source === "resolved" + ? settings.config + : await readDailyNotesConfig({ vaultPath, envSettings: settings?.envSettings }, logger) const unsupportedTokens = findUnsupportedTokens(config.format) @@ -182,8 +326,11 @@ export const getDailyNotePath = async ( throw new Error(describeNonCalendarDate(date)) } - const filename = dateTime.toFormat(luxonFormat) - return `${config.folder}/${filename}.md` + // Obsidian trims the formatted date before building the path, so a format + // with a space at either end still names "2026-05-13.md". + const filename = dateTime.toFormat(luxonFormat).trim() + + return config.folder ? `${config.folder}/${filename}.md` : `${filename}.md` } type DailyNoteResult = { @@ -198,7 +345,7 @@ export const getDailyNote = async ( params: { vaultPath: string date?: string | undefined - envSettings?: DailyNotesEnvSettings | undefined + settings?: DailyNotesSettings | undefined }, logger: Logger, ): Promise => { @@ -206,7 +353,7 @@ export const getDailyNote = async ( { vaultPath: params.vaultPath, date: params.date, - envSettings: params.envSettings, + settings: params.settings, }, logger, ) diff --git a/src/vault-mcp/vault-operations/vault-folder-config.ts b/src/vault-mcp/vault-operations/vault-folder-config.ts index 6aa86e4c8..a28307291 100644 --- a/src/vault-mcp/vault-operations/vault-folder-config.ts +++ b/src/vault-mcp/vault-operations/vault-folder-config.ts @@ -1,6 +1,12 @@ import type { VaultConfig } from "../config.js" import type { Logger } from "../../logger.js" -import { readDailyNotesConfig, readDailyNotesFileConfig } from "./daily-notes.js" +import { readDailyNotesConfig, readProtectedDailyNotesFolder } from "./daily-notes.js" + +/** The daily folder to add to a default list, or null when there is none to + * add: a vault-root or blank setting (null) and a whitespace-only one. */ +const pickDailyFolderToAdd = (dailyFolder: string | null): string | null => { + return dailyFolder?.trim() ? dailyFolder : null +} /** The folders that delete and move refuse to touch. * - PROTECTED_PATHS, when set, replaces the defaults entirely, so only the @@ -9,6 +15,10 @@ import { readDailyNotesConfig, readDailyNotesFileConfig } from "./daily-notes.js * plus the daily notes folder, resolved on each call (DAILY_NOTES_FOLDER → * .obsidian/daily-notes.json → "Daily Notes") so a folder configured only * in the vault is protected too. + * - No daily folder is protected when the setting is the vault root, from + * DAILY_NOTES_FOLDER or daily-notes.json, or is blank in daily-notes.json, + * which means Obsidian's new-note folder. Protecting either folder would + * cover every other note in it. * Throws when daily-notes.json exists but cannot be read, since the folder * it names is then unknown; DAILY_NOTES_FOLDER or PROTECTED_PATHS bypasses * the file. */ @@ -18,16 +28,23 @@ export const resolveEffectiveProtectedPaths = async ( ): Promise => { if (config.protectedPathsOverride) return config.protectedPathsOverride - // loadConfig trims DAILY_NOTES_FOLDER, so a set value is never blank. - if (config.dailyNotesFolder) return [config.memoryDir, config.dailyNotesFolder] + // DAILY_NOTES_FOLDER="/" loads as "" (the vault root), which protects no + // daily folder. loadConfig trims and rejects blank values, so any other set + // value needs no pickDailyFolderToAdd check. + if (config.dailyNotesFolder !== undefined) { + return config.dailyNotesFolder + ? [config.memoryDir, config.dailyNotesFolder] + : [config.memoryDir] + } - const dailyNotesConfig = await readDailyNotesFileConfig(vaultPath, logger) + const protectedDailyFolder = await readProtectedDailyNotesFolder(vaultPath, logger) + const dailyFolder = pickDailyFolderToAdd(protectedDailyFolder) - // A whitespace-only folder in daily-notes.json protects nothing. - const dailyFolder = dailyNotesConfig.folder.replace(/\/+$/, "") - return dailyFolder.trim() ? [config.memoryDir, dailyFolder] : [config.memoryDir] + return dailyFolder ? [config.memoryDir, dailyFolder] : [config.memoryDir] } +/** The folders orphan results leave out: ORPHAN_EXCLUDE_FOLDERS when set, + * otherwise the daily notes folder, "Templates", and the memory dir. */ export const resolveEffectiveOrphanExcludeFolders = ({ orphanExcludeFoldersOverride, memoryDir, @@ -35,28 +52,32 @@ export const resolveEffectiveOrphanExcludeFolders = ({ }: { orphanExcludeFoldersOverride: readonly string[] | null memoryDir: string - dailyNotesFolder: string + /** Null when the daily folder setting is the vault root or blank. */ + dailyNotesFolder: string | null }): readonly string[] => { if (orphanExcludeFoldersOverride) return orphanExcludeFoldersOverride - /** Whitespace-only settings exclude no daily folder; spaces in a nonblank folder name stay. */ - return dailyNotesFolder.trim() - ? [dailyNotesFolder, "Templates", memoryDir] - : ["Templates", memoryDir] + const dailyFolder = pickDailyFolderToAdd(dailyNotesFolder) + + return dailyFolder ? [dailyFolder, "Templates", memoryDir] : ["Templates", memoryDir] } -/** Only the daily folder affects orphan exclusions, so its env override skips file I/O. */ +/** resolveEffectiveOrphanExcludeFolders for callers holding only the config: + * reads .obsidian/daily-notes.json unless ORPHAN_EXCLUDE_FOLDERS or + * DAILY_NOTES_FOLDER already settles the list. */ export const readEffectiveOrphanExcludeFolders = async ( { config, vaultPath }: { config: VaultConfig; vaultPath: string }, logger: Logger, ): Promise => { if (config.orphanExcludeFoldersOverride) return config.orphanExcludeFoldersOverride - if (config.dailyNotesFolder) { + // The daily folder is the only default that varies, so an env value for + // it means no file read. The vault root ("") adds no daily folder. + if (config.dailyNotesFolder !== undefined) { return resolveEffectiveOrphanExcludeFolders({ orphanExcludeFoldersOverride: null, memoryDir: config.memoryDir, - dailyNotesFolder: config.dailyNotesFolder, + dailyNotesFolder: config.dailyNotesFolder || null, }) } @@ -64,6 +85,6 @@ export const readEffectiveOrphanExcludeFolders = async ( return resolveEffectiveOrphanExcludeFolders({ orphanExcludeFoldersOverride: null, memoryDir: config.memoryDir, - dailyNotesFolder: dailyNotesConfig.folder, + dailyNotesFolder: dailyNotesConfig.dedicatedFolder, }) }