diff --git a/.devin/wiki.json b/.devin/wiki.json index 9fe45e5e0..95975131b 100644 --- a/.devin/wiki.json +++ b/.devin/wiki.json @@ -4,7 +4,7 @@ "content": "Vault Cortex is a remote MCP (Model Context Protocol) server that exposes an Obsidian vault over HTTPS — vault CRUD, hybrid search, task management, structured memory, link-graph queries, and non-markdown file reading (images, PDFs, canvases, data files), plus user-initiated prompts. It runs headless in Docker, working directly with .md files on disk using its own Obsidian-parity parsers and search index. README.md is the authoritative feature description and ARCHITECTURE.md the authoritative design document — derive capability details, defaults, and security specifics from those and the code rather than restating them. The repo also ships a CLI (cli/, published as the vault-cortex npm package) that scaffolds a deployment: npx vault-cortex@latest init." }, { - "content": "The codebase is organized into four internal layers under src/vault-mcp/: (1) obsidian-markdown/ — pure Obsidian/Markdown parsers with no I/O (links, lines/fences, frontmatter, callouts, headings, tasks, memory entries, canvas, plaintext). (2) vault-operations/ — filesystem I/O for vault content (vault-filesystem, vault-patcher, note-mover, memory-store, daily-notes, task-mutations, task-format-config, trash-sweeper, asset-operations). (3) search/ — the SQLite FTS5 + sqlite-vec index, embedding pipeline, and chokidar file watcher. (4) mcp-core/ — the MCP protocol surface: streamable-http transport, tool group modules under mcp-core/tools/, and prompt group modules under mcp-core/prompts/. Authentication lives in src/vault-mcp/oauth/ (OAuth 2.1) and src/functions/authorizer.ts (AWS Lambda, path-aware). The :remote image's setup mode — the browser sign-in served before an Obsidian Sync token exists — lives in src/vault-mcp/setup/. Infrastructure-as-code (sst.config.ts) is specific to the AWS reference deployment and should not be documented as the primary deployment path." + "content": "The codebase is organized into four internal layers under src/vault-mcp/: (1) obsidian-markdown/ — pure Obsidian/Markdown parsers with no I/O (links, lines/fences, frontmatter, callouts, headings, tasks, memory entries, canvas, plaintext). (2) vault-operations/ — filesystem I/O for vault content (vault-filesystem, vault-patcher, note-mover, memory-store, daily-notes, vault-folder-config, task-mutations, task-format-config, trash-sweeper, asset-operations). (3) search/ — the SQLite FTS5 + sqlite-vec index, embedding pipeline, and chokidar file watcher. (4) mcp-core/ — the MCP protocol surface: streamable-http transport, tool group modules under mcp-core/tools/, and prompt group modules under mcp-core/prompts/. Authentication lives in src/vault-mcp/oauth/ (OAuth 2.1) and src/functions/authorizer.ts (AWS Lambda, path-aware). The :remote image's setup mode — the browser sign-in served before an Obsidian Sync token exists — lives in src/vault-mcp/setup/. Infrastructure-as-code (sst.config.ts) is specific to the AWS reference deployment and should not be documented as the primary deployment path." } ], "pages": [ @@ -22,7 +22,7 @@ }, { "title": "Configuration Reference", - "purpose": "All environment variables as defined in config.ts and the .env.example files, the ServerConfig type, and how configuration differs across the deployment modes. Enumerate the variables from the source at index time." + "purpose": "All environment variables as defined in config.ts and the .env.example files, the VaultConfig type, and how configuration differs across the deployment modes. Enumerate the variables from the source at index time." }, { "title": "Architecture", @@ -45,7 +45,7 @@ }, { "title": "Vault Operations", - "purpose": "The filesystem I/O layer in vault-operations/: vault-filesystem.ts (base primitives including atomic writes and path safety), vault-patcher.ts, note-mover.ts, memory-store.ts, daily-notes.ts, task-mutations.ts, task-format-config.ts, trash-sweeper.ts (boot-time orphan-row purge + daily retention sweep over recorded .trash/ entries), and asset-operations.ts (the use-case behind vault_read_file and vault_list_files). Derive the safety mechanisms (locking modes, TOCTOU guards, protected paths) from ARCHITECTURE.md's Data integrity section.", + "purpose": "The filesystem I/O layer in vault-operations/: vault-filesystem.ts (base primitives including atomic writes and path safety), vault-patcher.ts, note-mover.ts, memory-store.ts, daily-notes.ts, vault-folder-config.ts (protected folders and orphan exclusion defaults), task-mutations.ts, task-format-config.ts, trash-sweeper.ts (boot-time orphan-row purge + daily retention sweep over recorded .trash/ entries), and asset-operations.ts (the use-case behind vault_read_file and vault_list_files). Derive the safety mechanisms (locking modes, TOCTOU guards, protected paths) from ARCHITECTURE.md's Data integrity section.", "parent": "Architecture" }, { diff --git a/.env.example b/.env.example index 35356cb4e..c63738bef 100644 --- a/.env.example +++ b/.env.example @@ -232,9 +232,13 @@ GHCR_USER= # (default "Daily Notes"). 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 — DAILY_NOTES_FOLDER when set, otherwise "Daily Notes" — plus -# Templates and MEMORY_DIR). +# 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. +# 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. # ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me # URL shown in OAuth discovery metadata (default: https://github.com/aliasunder/vault-cortex) # SERVICE_DOCUMENTATION_URL=https://github.com/youruser/your-fork diff --git a/AGENTS.md b/AGENTS.md index 671f6aa91..31b2d6862 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,7 +147,7 @@ src/ authorizer.ts # Lambda: path-aware auth (OAuth pass-through, JWT + static) vault-mcp/ server.ts # Entry point — config, mount routes, listen - config.ts # Env-var loader + ServerConfig type (loadConfig) + config.ts # Env-var loader + VaultConfig type (loadConfig) obsidian-markdown/ # Pure Obsidian/Markdown parsers + transforms (no I/O) lines.ts # splitIntoLines (CRLF) + fence state machine + classifyLines + pageTextByLines (line paging) frontmatter.ts # gray-matter parse/stringify + frontmatter merge @@ -167,6 +167,7 @@ src/ note-mover.ts # Move/rename a note + rewrite every vault-wide link to it memory-store.ts # About Me/ heading-aware read/append/delete daily-notes.ts # Daily note config reader + path resolver (env settings > daily-notes.json) + vault-folder-config.ts # Protected folders + live orphan exclusion defaults task-mutations.ts # Task create + state mutations (status, priority, heading moves, sub-tasks) task-format-config.ts # Tasks-plugin format config reader (emoji vs Dataview) + status registry trash-config.ts # Obsidian "Deleted files" config reader (trashOption from .obsidian/app.json) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3c78f804a..eb1cd375b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -288,7 +288,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` excludes folders listed in `ORPHAN_EXCLUDE_FOLDERS` (default: the daily notes folder — `DAILY_NOTES_FOLDER` or `Daily Notes` — plus `Templates` and the memory dir). +- **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. + - `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. ### Files @@ -1100,7 +1103,7 @@ The runtime image (`Dockerfile`) minimizes the attack surface: | Debian security fixes | `apt-get upgrade` at build time covers the node-image rebuild window | | Log rotation (Compose) | `max-size: 10m`, `max-file: 3` — prevents disk exhaustion | | Explicit proxy trust (Express) | `trust proxy` = `TRUST_PROXY_HOPS` (default 0 — direct exposure); the `Forwarded` header is honored only under a non-zero `TRUST_FORWARDED_HOPS` — injected forwarding headers can't spoof the client IP (OAuth rate-limit bucket key, request logs) | -| `Object.freeze` on config | Prevents accidental mutation of the loaded `ServerConfig` — defense against programming errors | +| `Object.freeze` on config | Prevents accidental mutation of the loaded `VaultConfig` — defense against programming errors | ### Durability diff --git a/DEPLOY.md b/DEPLOY.md index 65027f551..f1e5d8780 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -365,7 +365,7 @@ To find your stage: `cat .sst/stage` (after one-time setup). | `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: `DAILY_NOTES_FOLDER, Templates, MEMORY_DIR`). 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. | | `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. | diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 0bc26371c..c556d255e 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 | @@ -167,7 +167,7 @@ All settings are environment variables with sensible defaults. Some defaults der | `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. The daily-notes part of the default comes from `DAILY_NOTES_FOLDER` only — this one doesn't read `daily-notes.json`. | +| `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). | | `TZ` | — | `UTC` | IANA timezone for timestamps and daily note resolution | diff --git a/README.md b/README.md index 2226594ce..594b9a752 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. The daily-notes part of the default comes from `DAILY_NOTES_FOLDER` only — this one doesn't read `daily-notes.json`. | -| `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`). 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. | See [`templates/memory/`](./templates/memory/) for memory file examples and the dated-entry design philosophy. @@ -420,6 +420,9 @@ When the file isn't available — or if you use the Periodic Notes plugin, whose You can set one or both — a set value always wins over the config file. Without either source, the server falls back to `Daily Notes` and `YYYY-MM-DD`. +- 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. + > **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/env.ts b/cli/src/env.ts index 87ff9608b..c40cdea0c 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -13,11 +13,8 @@ export type RemoteEnvAnswers = { vaultPassword?: string } -// Optional env blocks are synced from deploy//.env.example by -// npm run sync:cli-env-blocks. Edit the deploy/ files, then re-run the script. -// cli/src/templates.test.ts asserts the CLI optional block vars match the -// deploy/ .env.example optional vars, so a new var breaks CI until both -// surfaces carry it. +// Run npm run sync:cli-env-blocks after editing deploy//.env.example. +// cli/src/__tests__/templates.test.ts checks that both surfaces list the same optional vars. // ┌─────────────────────────────────────────────────────────────────────────┐ // │ GENERATED — do not edit between sync markers. │ @@ -78,7 +75,8 @@ EMBEDDING_ENABLED=true RERANK_MODE=blended # Enable or disable the memory layer (default: true). -# Set to false to hide memory tools and skip About Me/ creation. +# Set to false to hide memory tools and skip memory-folder creation. +# MEMORY_DIR still supplies default protected folders and orphan exclusions when false. MEMORY_ENABLED=true # Enable or disable file tools — vault_read_file and vault_list_files (default: true). # Set to false when Obsidian Sync has attachment syncing disabled. @@ -108,9 +106,16 @@ MEMORY_DIR=About Me # 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 — DAILY_NOTES_FOLDER when set, otherwise "Daily Notes" — plus -# Templates and MEMORY_DIR). +# 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. +# 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 +# or recreate the Compose container. +# Obsidian settings apply on the next query; daily-notes.json is read +# directly from your bind-mounted vault. # ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me # URL shown in OAuth discovery metadata @@ -218,7 +223,8 @@ MAX_IMAGE_OUTPUT_BYTES=49152 MAX_PDF_RENDER_PAGES=5 # Enable or disable the memory layer (default: true). -# Set to false to hide memory tools and skip About Me/ creation. +# Set to false to hide memory tools and skip memory-folder creation. +# MEMORY_DIR still supplies default protected folders and orphan exclusions when false. MEMORY_ENABLED=true # Enable or disable file tools — vault_read_file and vault_list_files (default: true). # Set to false when Obsidian Sync has attachment syncing disabled. @@ -249,9 +255,15 @@ MEMORY_DIR=About Me # 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 — DAILY_NOTES_FOLDER when set, otherwise "Daily Notes" — plus -# Templates and MEMORY_DIR). +# 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. +# 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 +# or recreate the Compose container. +# Obsidian settings apply on the next query once daily-notes.json reaches the server. # ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me # URL shown in OAuth discovery metadata diff --git a/deploy/local/.env.example b/deploy/local/.env.example index 641b7652b..ebcb35390 100644 --- a/deploy/local/.env.example +++ b/deploy/local/.env.example @@ -65,7 +65,8 @@ EMBEDDING_ENABLED=true RERANK_MODE=blended # Enable or disable the memory layer (default: true). -# Set to false to hide memory tools and skip About Me/ creation. +# Set to false to hide memory tools and skip memory-folder creation. +# MEMORY_DIR still supplies default protected folders and orphan exclusions when false. MEMORY_ENABLED=true # Enable or disable file tools — vault_read_file and vault_list_files (default: true). # Set to false when Obsidian Sync has attachment syncing disabled. @@ -95,9 +96,16 @@ MEMORY_DIR=About Me # 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 — DAILY_NOTES_FOLDER when set, otherwise "Daily Notes" — plus -# Templates and MEMORY_DIR). +# 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. +# 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 +# or recreate the Compose container. +# Obsidian settings apply on the next query; daily-notes.json is read +# directly from your bind-mounted vault. # ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me # URL shown in OAuth discovery metadata diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example index 2aa5e688f..04fe00eda 100644 --- a/deploy/remote/.env.example +++ b/deploy/remote/.env.example @@ -71,7 +71,8 @@ MAX_IMAGE_OUTPUT_BYTES=49152 MAX_PDF_RENDER_PAGES=5 # Enable or disable the memory layer (default: true). -# Set to false to hide memory tools and skip About Me/ creation. +# Set to false to hide memory tools and skip memory-folder creation. +# MEMORY_DIR still supplies default protected folders and orphan exclusions when false. MEMORY_ENABLED=true # Enable or disable file tools — vault_read_file and vault_list_files (default: true). # Set to false when Obsidian Sync has attachment syncing disabled. @@ -102,9 +103,15 @@ MEMORY_DIR=About Me # 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 — DAILY_NOTES_FOLDER when set, otherwise "Daily Notes" — plus -# Templates and MEMORY_DIR). +# 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. +# 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 +# or recreate the Compose container. +# Obsidian settings apply on the next query once daily-notes.json reaches the server. # ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me # URL shown in OAuth discovery metadata diff --git a/docker-compose.yml b/docker-compose.yml index 1b80765d5..6fc8b95ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,8 +31,8 @@ services: SYNC_MODE: ${SYNC_MODE:-bidirectional} SYNC_EXCLUDED_FOLDERS: ${SYNC_EXCLUDED_FOLDERS:-} SYNC_FILE_TYPES: ${SYNC_FILE_TYPES:-} - # Obsidian config-category sync (default: core plugin data, so the - # vault's daily-notes settings reach the server). "none" disables; + # Obsidian config-category sync (default: core and community plugin data, + # including daily-note settings and the Tasks plugin format). "none" disables; # the category values are listed in .env.example. SYNC_CONFIGS: ${SYNC_CONFIGS:-core-plugin-data,community-plugin-data} # --- MCP server (svc-vault-mcp) --- @@ -99,8 +99,9 @@ services: MEMORY_DIR: ${MEMORY_DIR:-About Me} # Left empty = the server applies the smart defaults listed below, where and # stand for the resolved values (defaults "About Me" and "Daily Notes"). - # ORPHAN_EXCLUDE_FOLDERS resolves its daily-notes value from the DAILY_NOTES_FOLDER env var - # alone — it never reads daily-notes.json. + # 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". # PROTECTED_PATHS default: ", " # ORPHAN_EXCLUDE_FOLDERS default: ", Templates, " # SERVICE_DOCUMENTATION_URL default: https://github.com/aliasunder/vault-cortex diff --git a/server.json b/server.json index 1045f9bfe..935272b6d 100644 --- a/server.json +++ b/server.json @@ -164,7 +164,7 @@ }, { "name": "ORPHAN_EXCLUDE_FOLDERS", - "description": "Comma-separated vault folder names excluded from vault_find_orphans. Default: DAILY_NOTES_FOLDER (else \"Daily Notes\"), \"Templates\", MEMORY_DIR." + "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." }, { "name": "SERVICE_DOCUMENTATION_URL", diff --git a/src/__tests__/integration/server-error-contracts.test.ts b/src/__tests__/integration/server-error-contracts.test.ts index 75ab8a128..9cc00e662 100644 --- a/src/__tests__/integration/server-error-contracts.test.ts +++ b/src/__tests__/integration/server-error-contracts.test.ts @@ -46,6 +46,29 @@ afterAll(async () => { } }) +// ── Orphan exclusion query capacity ────────────────────────── + +describe("orphan exclusion query capacity", () => { + it("returns a structured tool error when the exclusion list exceeds query capacity", async () => { + const result = await callTool({ + client, + name: "vault_find_orphans", + args: { + exclude_folders: Array.from({ length: 1000 }, (_, index) => `Folder${index}`), + }, + }) + expect(result).toEqual({ + content: [ + { + type: "text", + text: "[Error]: too many excluded folders", + }, + ], + isError: true, + }) + }) +}) + // ── Protected paths ────────────────────────────────────────── describe("protected path refusals", () => { diff --git a/src/vault-mcp/__tests__/config.test.ts b/src/vault-mcp/__tests__/config.test.ts index 97bb96216..517c60c5b 100644 --- a/src/vault-mcp/__tests__/config.test.ts +++ b/src/vault-mcp/__tests__/config.test.ts @@ -16,9 +16,9 @@ describe("loadConfig", () => { expect(config.protectedPathsOverride).toBeNull() }) - it("orphanExcludeFolders defaults to Daily Notes, Templates, About Me", () => { + it("orphanExcludeFoldersOverride is null when ORPHAN_EXCLUDE_FOLDERS is unset", () => { const config = loadConfig(EMPTY_ENV) - expect(config.orphanExcludeFolders).toEqual(["Daily Notes", "Templates", "About Me"]) + expect(config.orphanExcludeFoldersOverride).toBeNull() }) it("serviceDocumentationUrl defaults to the GitHub repo", () => { @@ -48,9 +48,9 @@ describe("loadConfig", () => { expect(config.memoryDir).toBe("Profile") }) - it("cascades into orphanExcludeFolders when ORPHAN_EXCLUDE_FOLDERS is not set", () => { + it("leaves the orphan override unset when ORPHAN_EXCLUDE_FOLDERS is not set", () => { const config = loadConfig({ MEMORY_DIR: "Profile" }) - expect(config.orphanExcludeFolders).toEqual(["Daily Notes", "Templates", "Profile"]) + expect(config.orphanExcludeFoldersOverride).toBeNull() }) it.each([ @@ -125,9 +125,9 @@ describe("loadConfig", () => { expect(config.dailyNotesFolder).toBe("Journal") }) - it("cascades into orphanExcludeFolders when ORPHAN_EXCLUDE_FOLDERS is not set", () => { + it("leaves the orphan override unset when ORPHAN_EXCLUDE_FOLDERS is not set", () => { const config = loadConfig({ DAILY_NOTES_FOLDER: "Journal" }) - expect(config.orphanExcludeFolders).toEqual(["Journal", "Templates", "About Me"]) + expect(config.orphanExcludeFoldersOverride).toBeNull() }) it("keeps the explicit PROTECTED_PATHS list when DAILY_NOTES_FOLDER is set", () => { @@ -290,11 +290,25 @@ describe("loadConfig", () => { }) describe("ORPHAN_EXCLUDE_FOLDERS (comma-separated)", () => { + it.each([ + { label: "an empty value", input: "" }, + { label: "a whitespace-only value", input: " " }, + ])("treats $label as no override", ({ input }) => { + expect(loadConfig({ ORPHAN_EXCLUDE_FOLDERS: input }).orphanExcludeFoldersOverride).toBeNull() + }) + + it.each([ + { label: "a single comma", input: "," }, + { label: "commas separated by spaces", input: ", ," }, + ])("$label clears the orphan exclusions", ({ input }) => { + expect(loadConfig({ ORPHAN_EXCLUDE_FOLDERS: input }).orphanExcludeFoldersOverride).toEqual([]) + }) + it("overrides the default entirely", () => { const config = loadConfig({ ORPHAN_EXCLUDE_FOLDERS: "Archive,Scratch", }) - expect(config.orphanExcludeFolders).toEqual(["Archive", "Scratch"]) + expect(config.orphanExcludeFoldersOverride).toEqual(["Archive", "Scratch"]) }) it("does not include MEMORY_DIR when explicitly set", () => { @@ -302,8 +316,8 @@ describe("loadConfig", () => { MEMORY_DIR: "Profile", ORPHAN_EXCLUDE_FOLDERS: "Archive,Scratch", }) - expect(config.orphanExcludeFolders).toEqual(["Archive", "Scratch"]) - expect(config.orphanExcludeFolders).not.toContain("Profile") + expect(config.orphanExcludeFoldersOverride).toEqual(["Archive", "Scratch"]) + expect(config.orphanExcludeFoldersOverride).not.toContain("Profile") }) it("validates each entry", () => { diff --git a/src/vault-mcp/config.ts b/src/vault-mcp/config.ts index 6884a4d29..b5b2d2076 100644 --- a/src/vault-mcp/config.ts +++ b/src/vault-mcp/config.ts @@ -40,7 +40,7 @@ const parseVaultFolderList = (raw: string): string[] => /** Validates a DAILY_NOTES_FORMAT value by probe-rendering a fixed date. * Structural checks only — structurally unsafe results (traversal, - * separators, empty) are rejected. Warns when the format contains + * leading/trailing slashes, empty) are rejected. Warns when the format contains * unsupported tokens. Returns the raw moment string unchanged. */ const validateDailyNotesFormat = (momentFormat: string): string => { const renderedProbe = DateTime.fromISO("2026-01-31").toFormat(momentToLuxonFormat(momentFormat)) @@ -122,7 +122,8 @@ export type VaultConfig = Readonly<{ /** PROTECTED_PATHS as the user set it; null when unset, in which case the * protected set (memory dir + daily notes folder) is resolved per call. */ protectedPathsOverride: readonly string[] | null - orphanExcludeFolders: readonly string[] + /** Null uses per-call defaults; an empty list excludes no folders. */ + orphanExcludeFoldersOverride: readonly string[] | null serviceDocumentationUrl: string /** When true, the embedding pipeline is active — notes are chunked, embedded * via a local ONNX model (bge-small-en-v1.5), and stored in sqlite-vec for @@ -184,15 +185,10 @@ export const loadConfig = (env: Record = process.env const protectedPathsRaw = env.PROTECTED_PATHS?.trim() const protectedPathsOverride = protectedPathsRaw ? parseVaultFolderList(protectedPathsRaw) : null - // The orphan default tracks the env-configured daily notes folder only - // (the vault's daily-notes.json can't cascade here — config load is - // synchronous env parsing; the file is read lazily at call time). - const dailyNotesFolderOrDefault = dailyNotesFolder ?? "Daily Notes" - const orphanExcludeFoldersRaw = env.ORPHAN_EXCLUDE_FOLDERS?.trim() - const orphanExcludeFolders = orphanExcludeFoldersRaw + const orphanExcludeFoldersOverride = orphanExcludeFoldersRaw ? parseVaultFolderList(orphanExcludeFoldersRaw) - : [dailyNotesFolderOrDefault, "Templates", memoryDir] + : null const serviceDocumentationUrl = env.SERVICE_DOCUMENTATION_URL?.trim() ? z.string().url().parse(env.SERVICE_DOCUMENTATION_URL.trim()) @@ -299,7 +295,7 @@ export const loadConfig = (env: Record = process.env dailyNotesFolder, dailyNotesFormat, protectedPathsOverride, - orphanExcludeFolders, + orphanExcludeFoldersOverride, serviceDocumentationUrl, embeddingEnabled, rerankMode, 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 320f540df..8cd93102a 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 @@ -272,12 +272,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json index 6b652140b..6d3c20a63 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 @@ -274,12 +274,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json index 388b9aeb4..17579c6cd 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 @@ -274,12 +274,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json index 1c35b4dc7..9317246d0 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 @@ -275,12 +275,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json index bde9251c8..8a435c5af 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 @@ -274,12 +274,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json index 906158bf4..b276cc08a 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 @@ -229,12 +229,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json index c4240e468..54a721e39 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 @@ -230,12 +230,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json index fa4466c9a..b1a757e78 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 @@ -229,12 +229,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json index dcb406f0f..9e0c3d696 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 @@ -228,12 +228,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json index 5f8c6e662..381c32a7f 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 @@ -274,12 +274,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph. Link an orphan by mentioning it from a relevant note with vault_patch_note.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", 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 f20ec2869..fa9c5b0f3 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json @@ -9,12 +9,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", 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 453ed3dc6..047c3c63c 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json @@ -10,12 +10,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", 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 2b29b4d2c..c2967c72c 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json @@ -9,12 +9,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", 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 491cc49f7..e72c88c50 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+embedding-off.json @@ -10,12 +10,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", 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 490b597be..e9ec2915b 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off+embedding-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off+embedding-off.json @@ -11,12 +11,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", 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 ca623c057..e306cf9a8 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off.json @@ -10,12 +10,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", 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 e6f2d4b9d..9cec5fe8a 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off.json @@ -9,12 +9,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", 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 4a2a36232..6636547d3 100644 --- a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json +++ b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json @@ -8,12 +8,12 @@ { "name": "vault_find_orphans", "title": "Find Orphans", - "description": "Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({ exclude_folders: [\"Daily Notes\",\"Templates\",\"About Me\"] })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", + "description": "Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored).\n\nExample: vault_find_orphans({})\nExample: vault_find_orphans({ exclude_folders: [\"Archive\"], limit: 10 })\n\nWhen to use: Vault maintenance — surfacing notes to integrate into the graph.\nPrefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault.\n\nParameters:\n- The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → \"Daily Notes\"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses \"Daily Notes\".\n- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included (\"Projects\" also excludes \"Projects/Archive\" but not \"ProjectsOld/\"), ignoring ASCII letter case.\n- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check.\n\nErrors:\n- An empty array means no orphans were found (after exclusions), not an error.\n- \"too many excluded folders\" — pass a shorter exclude_folders list, then retry.\n\nReturns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.", "inputSchema": { "type": "object", "properties": { "exclude_folders": { - "description": "Folders to exclude (default [\"Daily Notes\",\"Templates\",\"About Me\"])", + "description": "Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, \"About Me\")", "type": "array", "items": { "type": "string", 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 4ac482e70..9dada6b3a 100644 --- a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts @@ -4,14 +4,14 @@ import { mkdtemp, rm, writeFile, mkdir, readFile, utimes } from "node:fs/promise import { join } from "node:path" import { tmpdir } from "node:os" import { DateTime } from "luxon" -import type { z } from "zod" +import { z } from "zod" import { computeEnabledToolNames, registerTools } from "../tool-definitions.js" import { TOOL_NAMES, TOOL_REGISTRY } from "../tool-registry.js" import { loadConfig } from "../../config.js" import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { createSearchIndex } from "../../search/search-index.js" import type { SearchIndex } from "../../search/search-index.js" -import { logger } from "../../../logger.js" +import { logger, type Logger } from "../../../logger.js" const ALL_TOOL_NAMES = Object.values(TOOL_NAMES) @@ -72,13 +72,16 @@ beforeEach(() => { }) /** The registerTool calls a server makes under the config this env produces. */ -const registerWithConfig = (env: Record): RegisterToolCall[] => { +const registerWithConfig = ( + env: Record, + context: { vaultPath?: string; search?: SearchIndex; logger?: Logger } = {}, +): RegisterToolCall[] => { const server = { registerTool: vi.fn() } registerTools({ server: server as unknown as McpServer, - vaultPath: "/test-vault", - search: {} as SearchIndex, - logger, + vaultPath: context.vaultPath ?? "/test-vault", + search: context.search ?? ({} as SearchIndex), + logger: context.logger ?? logger, config: loadConfig(env), }) return server.registerTool.mock.calls as RegisterToolCall[] @@ -486,10 +489,13 @@ describe("config interpolation in descriptions", () => { expect(config.description).toContain("use vault_delete_memory for memory entries") }) - it("vault_find_orphans description references configured exclusion folders", () => { + it("vault_find_orphans schema references configured exclusion folders", () => { const [, config] = requireCustomCall(TOOL_NAMES.VAULT_FIND_ORPHANS) - expect(config.description).toContain(CUSTOM_MEMORY_DIR) - expect(config.description).not.toContain("About Me") + const exclusionDescription = config.inputSchema?.exclude_folders?.description + + expect(exclusionDescription).toBe( + 'Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, "Profile")', + ) }) }) @@ -1403,6 +1409,243 @@ describe("vault_search handler", () => { }) }) +describe("vault_find_orphans live folder defaults", () => { + const setupOrphans = async ( + options: { + settings?: string + env?: Record + paths?: readonly string[] + } = {}, + ) => { + const vaultPath = await mkdtemp(join(tmpdir(), "orphan-handler-")) + onTestFinished(() => rm(vaultPath, { recursive: true, force: true })) + await mkdir(join(vaultPath, ".obsidian")) + const settingsPath = join(vaultPath, ".obsidian/daily-notes.json") + + if (options.settings !== undefined) await writeFile(settingsPath, options.settings) + + const search = createSearchIndex(":memory:") + const paths = options.paths ?? [ + "Journal/daily.md", + "Journal/nested/daily.md", + "JournalOld/note.md", + "ordinary.md", + "Daily Notes/daily.md", + "Templates/template.md", + "About Me/memory.md", + "Archive/note.md", + ] + paths.forEach((filePath, index) => { + search.upsertNote( + { filePath, rawContent: "# Note\n", fileStat: { mtimeMs: 10000 - index, size: 7 } }, + logger, + ) + }) + const requestLogger: Logger = { ...logger, warn: vi.fn(), child: () => requestLogger } + const registeredCalls = registerWithConfig(options.env ?? {}, { + vaultPath, + search, + logger: requestLogger, + }) + const orphanCall = registeredCalls.find(([name]) => name === TOOL_NAMES.VAULT_FIND_ORPHANS) + + if (!orphanCall) throw new Error("vault_find_orphans not registered") + + const queryPaths = async (args: { exclude_folders?: string[]; limit?: number } = {}) => { + const result = z + .object({ + content: z.array(z.object({ text: z.string() })), + isError: z.boolean().optional(), + }) + .parse(await orphanCall[2](args, { requestId: "orphan-request" })) + expect(result.isError).toBeUndefined() + return z + .array(z.object({ path: z.string() })) + .parse(JSON.parse(requireTextContent(result))) + .map((note) => note.path) + } + return { settingsPath, queryPaths, requestLogger, toolConfig: orphanCall[1] } + } + + it("excludes a file-only daily folder and descendants but keeps sibling decoys", async () => { + const { queryPaths } = await setupOrphans({ settings: '{"folder":"Journal"}' }) + expect(await queryPaths()).toEqual([ + "JournalOld/note.md", + "ordinary.md", + "Daily Notes/daily.md", + "Archive/note.md", + ]) + }) + + it("excludes daily notes before a limit of one", async () => { + const { queryPaths } = await setupOrphans({ settings: '{"folder":"Journal"}' }) + expect(await queryPaths({ limit: 1 })).toEqual(["JournalOld/note.md"]) + }) + + it("uses the env daily folder over the file folder without reading malformed settings", async () => { + const { queryPaths, requestLogger } = await setupOrphans({ + settings: "broken", + env: { DAILY_NOTES_FOLDER: "Journal" }, + }) + expect(await queryPaths()).toEqual([ + "JournalOld/note.md", + "ordinary.md", + "Daily Notes/daily.md", + "Archive/note.md", + ]) + expect(requestLogger.warn).not.toHaveBeenCalled() + }) + + it("replaces all defaults with the explicit environment list", async () => { + const { queryPaths, requestLogger } = await setupOrphans({ + settings: "broken", + env: { ORPHAN_EXCLUDE_FOLDERS: "Archive" }, + }) + expect(await queryPaths()).toEqual([ + "Journal/daily.md", + "Journal/nested/daily.md", + "JournalOld/note.md", + "ordinary.md", + "Daily Notes/daily.md", + "Templates/template.md", + "About Me/memory.md", + ]) + expect(requestLogger.warn).not.toHaveBeenCalled() + }) + + it("lets a request list replace the environment list", async () => { + const { queryPaths } = await setupOrphans({ env: { ORPHAN_EXCLUDE_FOLDERS: "Archive" } }) + expect(await queryPaths({ exclude_folders: ["Journal"] })).toEqual([ + "JournalOld/note.md", + "ordinary.md", + "Daily Notes/daily.md", + "Templates/template.md", + "About Me/memory.md", + "Archive/note.md", + ]) + }) + + it.each([ + { label: "without an environment override", env: {} }, + { label: "over an environment override", env: { ORPHAN_EXCLUDE_FOLDERS: "Archive" } }, + ])( + "returns every folder for request [] $label and bypasses malformed settings", + async ({ env }) => { + const { queryPaths, requestLogger } = await setupOrphans({ + settings: "broken", + env, + }) + expect(await queryPaths({ exclude_folders: [] })).toEqual([ + "Journal/daily.md", + "Journal/nested/daily.md", + "JournalOld/note.md", + "ordinary.md", + "Daily Notes/daily.md", + "Templates/template.md", + "About Me/memory.md", + "Archive/note.md", + ]) + expect(requestLogger.warn).not.toHaveBeenCalled() + }, + ) + + it("uses a comma-only environment list as no exclusions", async () => { + const { queryPaths, requestLogger } = await setupOrphans({ + settings: "broken", + env: { ORPHAN_EXCLUDE_FOLDERS: ", ," }, + }) + expect(await queryPaths()).toEqual([ + "Journal/daily.md", + "Journal/nested/daily.md", + "JournalOld/note.md", + "ordinary.md", + "Daily Notes/daily.md", + "Templates/template.md", + "About Me/memory.md", + "Archive/note.md", + ]) + expect(requestLogger.warn).not.toHaveBeenCalled() + }) + + it("excludes a custom memory directory even when memory is disabled", async () => { + const { queryPaths } = await setupOrphans({ + env: { MEMORY_DIR: "Profile", MEMORY_ENABLED: "false" }, + paths: ["Profile/memory.md", "Profile/sub/memory.md", "About Me/note.md", "ordinary.md"], + }) + expect(await queryPaths()).toEqual(["About Me/note.md", "ordinary.md"]) + }) + + it("applies file folder changes without registering the handler again", async () => { + const { queryPaths, settingsPath } = await setupOrphans({ + settings: '{"folder":"Journal"}', + paths: ["Journal/daily.md", "Planner/Daily/daily.md", "ordinary.md"], + }) + expect(await queryPaths()).toEqual(["Planner/Daily/daily.md", "ordinary.md"]) + await writeFile(settingsPath, '{"folder":"Planner/Daily"}') + expect(await queryPaths()).toEqual(["Journal/daily.md", "ordinary.md"]) + }) + + it("follows valid, malformed, and repaired settings in the same process", async () => { + const { queryPaths, settingsPath, requestLogger } = await setupOrphans({ + settings: '{"folder":"Journal"}', + paths: ["Journal/daily.md", "Daily Notes/daily.md", "Planner/Daily/daily.md", "ordinary.md"], + }) + expect(await queryPaths()).toEqual([ + "Daily Notes/daily.md", + "Planner/Daily/daily.md", + "ordinary.md", + ]) + await writeFile(settingsPath, "broken") + expect(await queryPaths()).toEqual([ + "Journal/daily.md", + "Planner/Daily/daily.md", + "ordinary.md", + ]) + expect(requestLogger.warn).toHaveBeenCalledTimes(1) + expect(requestLogger.warn).toHaveBeenCalledWith( + "cannot read daily notes config, using defaults", + { error: expect.any(String) }, + ) + await writeFile(settingsPath, '{"folder":"Planner/Daily"}') + expect(await queryPaths()).toEqual(["Journal/daily.md", "Daily Notes/daily.md", "ordinary.md"]) + }) + + it("uses settings that arrive after handler registration", async () => { + const { queryPaths, settingsPath } = await setupOrphans({ + paths: ["Journal/daily.md", "Daily Notes/daily.md", "ordinary.md"], + }) + expect(await queryPaths()).toEqual(["Journal/daily.md", "ordinary.md"]) + await writeFile(settingsPath, '{"folder":"Journal"}') + expect(await queryPaths()).toEqual(["Daily Notes/daily.md", "ordinary.md"]) + }) + + it("describes live sources and states the exclusion default in the schema", async () => { + const { toolConfig } = await setupOrphans({ settings: '{"folder":"Journal"}' }) + expect(toolConfig.description).toContain( + 'The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → "Daily Notes")', + ) + expect(toolConfig.description).not.toContain("Journal") + expect(toolConfig.inputSchema?.exclude_folders?.description).toBe( + 'Folder paths to exclude (e.g. Projects; default: daily notes folder, Templates, "About Me")', + ) + }) + + it("describes an explicit environment list instead of implicit default sources", async () => { + const { toolConfig } = await setupOrphans({ + env: { ORPHAN_EXCLUDE_FOLDERS: "Archive,Scratch" }, + }) + const defaultsLine = toolConfig.description + ?.split("\n") + .find((line) => line.startsWith("- With exclude_folders")) + expect(defaultsLine).toBe( + "- With exclude_folders omitted, the ORPHAN_EXCLUDE_FOLDERS override is used.", + ) + expect(toolConfig.inputSchema?.exclude_folders?.description).toBe( + 'Folder paths to exclude (e.g. Projects; default: ["Archive","Scratch"])', + ) + }) +}) + describe("vault_list_tasks handler", () => { const mockExtra = { requestId: "test-1", sessionId: "session-1" } 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 7c5de7620..505627d92 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 @@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" import { registerPrompts } from "../../prompt-definitions.js" +import { readDailyNotesConfig } from "../../../vault-operations/daily-notes.js" import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { type RegisterPromptCall, @@ -20,12 +21,244 @@ import { logger, } from "./prompt-test-harness.js" +vi.mock("../../../vault-operations/daily-notes.js", { spy: true }) + afterEach(() => { vi.restoreAllMocks() }) // ── vault-orientation handler ──────────────────────────────────── +describe("vault-orientation live orphan folders", () => { + const dailyForwardReferences = + "[[Journal/2026-10-06]] [[Journal/2026-10-07]] [[Daily Notes/2026-10-06]] [[missing]]" + + const setupOrphanPrompt = async ( + options: { + settings?: string + env?: Record + paths?: readonly string[] + noteContents?: Readonly> + } = {}, + ) => { + const vault = await mkdtemp(join(tmpdir(), "orientation-orphans-")) + onTestFinished(() => rm(vault, { recursive: true, force: true })) + await mkdir(join(vault, ".obsidian")) + const settingsPath = join(vault, ".obsidian/daily-notes.json") + + if (options.settings !== undefined) await writeFile(settingsPath, options.settings) + + const search = createSearchIndex(":memory:") + const paths = options.paths ?? [ + "Journal/daily.md", + "Journal/nested/daily.md", + "JournalOld/note.md", + "ordinary.md", + "Daily Notes/daily.md", + "Templates/template.md", + "Profile/memory.md", + "Archive/note.md", + ] + paths.forEach((filePath, index) => { + const rawContent = options.noteContents?.[filePath] ?? "" + search.upsertNote( + { + filePath, + rawContent, + fileStat: { mtimeMs: 10000 - index, size: Buffer.byteLength(rawContent) }, + }, + logger, + ) + }) + const logCalls: LogCall[] = [] + const config = loadConfig({ MEMORY_ENABLED: "false", MEMORY_DIR: "Profile", ...options.env }) + const calls = registerWithSearch(vault, search, recordingLogger(logCalls), config) + const handler = findCall(calls, PROMPT_NAMES.VAULT_ORIENTATION)[2] + const readSurveySections = async () => { + const text = textOf(await handler(fakeExtra)) + const orphans = text.split("## Orphans\n")[1]?.split("\n\n---")[0]?.split("\n\n## ")[0] + const stats = text.split("## Vault stats\n")[1]?.split("\n\n## Folders")[0] + + if (!orphans || !stats) throw new Error("prompt has no orphan or stats section") + return { orphans, stats } + } + const orphanSection = async () => (await readSurveySections()).orphans + return { vault, settingsPath, orphanSection, readSurveySections, logCalls, config } + } + + it("excludes file-configured daily notes and descendants while retaining sibling folders", async () => { + const { orphanSection } = await setupOrphanPrompt({ settings: '{"folder":"Journal"}' }) + expect(await orphanSection()).toBe( + "4 orphan notes (no incoming links):\n- JournalOld/note.md — note\n- ordinary.md — ordinary\n- Daily Notes/daily.md — daily\n- Archive/note.md — note", + ) + }) + + it.each(["Journal/", "Journal///"])( + "renders one trailing separator for the excluded daily folder %s", + async (dailyNotesFolder) => { + const { readSurveySections } = await setupOrphanPrompt({ + settings: JSON.stringify({ folder: dailyNotesFolder }), + paths: ["source.md", "other.md"], + noteContents: { "source.md": dailyForwardReferences }, + }) + + expect((await readSurveySections()).stats).toBe( + "2 notes across 0 folders, 0 tags, 0 property keys. 2 untagged. 2 without properties. 2 broken links (excludes 2 forward-refs in Journal/).", + ) + }, + ) + + 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({ + settings: '{"folder":"Journal"}', + paths: [...dailyPaths, "ordinary.md", "JournalOld/note.md"], + }) + expect(await orphanSection()).toBe( + "2 orphan notes (no incoming links):\n- ordinary.md — ordinary\n- JournalOld/note.md — note", + ) + }) + + it("describes an empty filtered result without claiming excluded notes are linked", async () => { + const { orphanSection } = await setupOrphanPrompt({ + settings: '{"folder":"Journal"}', + paths: ["Journal/daily.md"], + }) + expect(await orphanSection()).toBe("No orphans found after folder exclusions.") + }) + + it("lets explicit environment folders replace all defaults", async () => { + const { orphanSection } = await setupOrphanPrompt({ + settings: '{"folder":"Journal"}', + env: { ORPHAN_EXCLUDE_FOLDERS: "Archive" }, + paths: [ + "Journal/daily.md", + "Daily Notes/daily.md", + "Templates/template.md", + "Profile/memory.md", + "Archive/note.md", + ], + }) + expect(await orphanSection()).toBe( + "4 orphan notes (no incoming links):\n- Journal/daily.md — daily\n- Daily Notes/daily.md — daily\n- Templates/template.md — template\n- Profile/memory.md — memory", + ) + }) + + it("uses the environment daily folder over the file folder", async () => { + const { orphanSection } = await setupOrphanPrompt({ + settings: '{"folder":"Journal"}', + env: { DAILY_NOTES_FOLDER: "Env/Daily" }, + paths: [ + "Env/Daily/daily.md", + "Env/Daily/nested/daily.md", + "Journal/daily.md", + "ordinary.md", + "Templates/template.md", + "Profile/memory.md", + ], + }) + expect(await orphanSection()).toBe( + "2 orphan notes (no incoming links):\n- Journal/daily.md — daily\n- ordinary.md — ordinary", + ) + }) + + it("honors an empty environment list and still reads daily settings once for broken links", async () => { + const { vault, readSurveySections, config } = await setupOrphanPrompt({ + settings: '{"folder":"Journal"}', + env: { ORPHAN_EXCLUDE_FOLDERS: ", ," }, + noteContents: { "Journal/daily.md": dailyForwardReferences }, + paths: [ + "Journal/daily.md", + "Daily Notes/daily.md", + "Templates/template.md", + "Profile/memory.md", + ], + }) + vi.mocked(readDailyNotesConfig).mockClear() + expect(await readSurveySections()).toEqual({ + orphans: + "4 orphan notes (no incoming links):\n- Journal/daily.md — daily\n- Daily Notes/daily.md — daily\n- Templates/template.md — template\n- Profile/memory.md — memory", + stats: + "4 notes across 0 folders, 0 tags, 0 property keys. 4 untagged. 4 without properties. 2 broken links (excludes 2 forward-refs in Journal/).", + }) + expect(vi.mocked(readDailyNotesConfig)).toHaveBeenCalledTimes(1) + expect(vi.mocked(readDailyNotesConfig)).toHaveBeenCalledWith( + { + vaultPath: vault, + envSettings: { folder: config.dailyNotesFolder, format: config.dailyNotesFormat }, + }, + expect.any(Object), + ) + }) + + it("uses the same single daily settings read for default exclusions and broken links", async () => { + const { readSurveySections } = await setupOrphanPrompt({ + settings: '{"folder":"Journal"}', + noteContents: { "ordinary.md": dailyForwardReferences }, + }) + vi.mocked(readDailyNotesConfig).mockClear() + expect(await readSurveySections()).toEqual({ + orphans: + "4 orphan notes (no incoming links):\n- JournalOld/note.md — note\n- ordinary.md — ordinary\n- Daily Notes/daily.md — daily\n- Archive/note.md — note", + stats: + "8 notes across 0 folders, 0 tags, 0 property keys. 8 untagged. 8 without properties. 2 broken links (excludes 2 forward-refs in Journal/).", + }) + expect(vi.mocked(readDailyNotesConfig)).toHaveBeenCalledTimes(1) + }) + + it("changes folder exclusions on the next invocation without registering again", async () => { + const { orphanSection, settingsPath } = await setupOrphanPrompt({ + settings: '{"folder":"Journal"}', + paths: ["Journal/daily.md", "Planner/Daily/daily.md", "ordinary.md"], + }) + expect(await orphanSection()).toBe( + "2 orphan notes (no incoming links):\n- Planner/Daily/daily.md — daily\n- ordinary.md — ordinary", + ) + await writeFile(settingsPath, '{"folder":"Planner/Daily"}') + expect(await orphanSection()).toBe( + "2 orphan notes (no incoming links):\n- Journal/daily.md — daily\n- ordinary.md — ordinary", + ) + }) + + it("falls back with a warning for malformed settings and uses a repaired file on the next call", async () => { + const { orphanSection, settingsPath, logCalls } = await setupOrphanPrompt({ + settings: '{"folder":"Journal"}', + paths: ["Journal/daily.md", "Daily Notes/daily.md", "Planner/Daily/daily.md", "ordinary.md"], + }) + expect(await orphanSection()).toBe( + "3 orphan notes (no incoming links):\n- Daily Notes/daily.md — daily\n- Planner/Daily/daily.md — daily\n- ordinary.md — ordinary", + ) + await writeFile(settingsPath, "broken") + expect(await orphanSection()).toBe( + "3 orphan notes (no incoming links):\n- Journal/daily.md — daily\n- Planner/Daily/daily.md — daily\n- ordinary.md — ordinary", + ) + expect(logCalls.filter((entry) => entry.level === "warn")).toEqual([ + { + level: "warn", + message: "cannot read daily notes config, using defaults", + data: { requestId: "1", prompt: "vault-orientation", error: expect.any(String) }, + }, + ]) + await writeFile(settingsPath, '{"folder":"Planner/Daily"}') + expect(await orphanSection()).toBe( + "3 orphan notes (no incoming links):\n- Journal/daily.md — daily\n- Daily Notes/daily.md — daily\n- ordinary.md — ordinary", + ) + }) + + it("uses file settings that arrive after prompt registration", async () => { + const { orphanSection, settingsPath } = await setupOrphanPrompt({ + paths: ["Journal/daily.md", "Daily Notes/daily.md", "ordinary.md"], + }) + expect(await orphanSection()).toBe( + "2 orphan notes (no incoming links):\n- Journal/daily.md — daily\n- ordinary.md — ordinary", + ) + await writeFile(settingsPath, '{"folder":"Journal"}') + expect(await orphanSection()).toBe( + "2 orphan notes (no incoming links):\n- Daily Notes/daily.md — daily\n- ordinary.md — ordinary", + ) + }) +}) + describe("vault-orientation handler", () => { it("returns sentinels and never throws on an empty vault", async () => { const config = loadConfig({}) @@ -122,7 +355,8 @@ describe("vault-orientation handler", () => { const handler = findCall(calls, PROMPT_NAMES.VAULT_ORIENTATION)[2] const text = textOf(await handler(fakeExtra)) - expect(text).toContain("No orphans found — every note has at least one incoming link.") + const orphanSection = text.split("## Orphans\n")[1]?.split("\n\n## Memory")[0] + expect(orphanSection).toBe("No orphans found after folder exclusions.") }) it("shows property adoption rates with count/total format", async () => { 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 4b1986094..5939338be 100644 --- a/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts +++ b/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts @@ -2,6 +2,7 @@ import { createMemoryStore, type MemoryFileOutline } from "../../vault-operations/memory-store.js" import { vaultFs } from "../../vault-operations/vault-filesystem.js" +import { resolveEffectiveOrphanExcludeFolders } from "../../vault-operations/vault-folder-config.js" import { readDailyNotesConfig } from "../../vault-operations/daily-notes.js" import { describeError } from "../../../utils/describe-error.js" import { compareByUtf8Bytes } from "../../../utils/compare-utf8-bytes.js" @@ -97,16 +98,18 @@ const formatMemoryOutline = (outlines: readonly MemoryFileOutline[]): string => outlines.map(formatMemoryOutlineEntry).join("\n") /** Formats the broken-link count for the stats line, including excluded - * forward-refs when present. Returns "" when there are no broken links. */ + * forward-refs when present. Returns "" when both counts are zero. */ const formatBrokenLinkSegment = (result: { count: number excludedFolder: string | null excludedCount: number }): string => { const { count, excludedFolder, excludedCount } = result + const excludedFolderLabel = `${excludedFolder}/`.replace(/\/+$/, "/") + const forwardReferenceLabel = excludedCount === 1 ? "forward-ref" : "forward-refs" const excludedNote = excludedCount > 0 - ? `excludes ${excludedCount} forward-ref${excludedCount === 1 ? "" : "s"} in ${excludedFolder}/` + ? `excludes ${excludedCount} ${forwardReferenceLabel} in ${excludedFolderLabel}` : "" if (count === 0 && excludedNote.length === 0) return "" @@ -161,22 +164,28 @@ export const registerVaultOrientationPrompt = ({ config.memoryEnabled && memoryStore ? await memoryStore.listMemoryFiles({ vaultPath }, reqLogger) : [] - const orphanResults = search.findOrphans( + const dailyNotesConfig = await readDailyNotesConfig( { - excludeFolders: [...config.orphanExcludeFolders], - limit: ORIENTATION_ORPHAN_LIMIT + 1, + vaultPath, + envSettings: { folder: config.dailyNotesFolder, format: config.dailyNotesFormat }, }, reqLogger, ) - const hasMoreOrphans = orphanResults.length > ORIENTATION_ORPHAN_LIMIT - const orphans = orphanResults.slice(0, ORIENTATION_ORPHAN_LIMIT) - const dailyNotesConfig = await readDailyNotesConfig( + const orphanResults = search.findOrphans( { - vaultPath, - envSettings: { folder: config.dailyNotesFolder, format: config.dailyNotesFormat }, + excludeFolders: [ + ...resolveEffectiveOrphanExcludeFolders({ + orphanExcludeFoldersOverride: config.orphanExcludeFoldersOverride, + memoryDir: config.memoryDir, + dailyNotesFolder: dailyNotesConfig.folder, + }), + ], + limit: ORIENTATION_ORPHAN_LIMIT + 1, }, reqLogger, ) + const hasMoreOrphans = orphanResults.length > ORIENTATION_ORPHAN_LIMIT + const orphans = orphanResults.slice(0, ORIENTATION_ORPHAN_LIMIT) const brokenLinkResult = search.brokenLinkCount( { dailyNotesFolder: dailyNotesConfig.folder }, reqLogger, @@ -227,7 +236,7 @@ export const registerVaultOrientationPrompt = ({ `${orphanCountLabel} orphan notes (no incoming links):`, ...orphans.map(formatNoteLine), ].join("\n") - : "No orphans found — every note has at least one incoming link." + : "No orphans found after folder exclusions." const memorySectionContent = memoryFiles.length > 0 diff --git a/src/vault-mcp/mcp-core/tools/__tests__/vault-crud-tools.test.ts b/src/vault-mcp/mcp-core/tools/__tests__/vault-crud-tools.test.ts deleted file mode 100644 index edba05d50..000000000 --- a/src/vault-mcp/mcp-core/tools/__tests__/vault-crud-tools.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import type { VaultConfig } from "../../../config.js" -import { logger } from "../../../../logger.js" - -vi.mock("../../../vault-operations/daily-notes.js", () => ({ - readDailyNotesFileConfig: vi.fn(), -})) - -import { resolveEffectiveProtectedPaths } from "../vault-crud-tools.js" -import { readDailyNotesFileConfig } from "../../../vault-operations/daily-notes.js" - -const mockedReadDailyNotesFileConfig = vi.mocked(readDailyNotesFileConfig) - -const makeConfig = ( - overrides: Partial< - Pick< - VaultConfig, - "memoryDir" | "protectedPathsOverride" | "dailyNotesFolder" | "dailyNotesFormat" - > - > = {}, -): VaultConfig => - ({ - memoryDir: overrides.memoryDir ?? "About Me", - protectedPathsOverride: overrides.protectedPathsOverride ?? null, - dailyNotesFolder: overrides.dailyNotesFolder, - dailyNotesFormat: overrides.dailyNotesFormat, - }) as unknown as VaultConfig - -describe("resolveEffectiveProtectedPaths", () => { - beforeEach(() => { - mockedReadDailyNotesFileConfig.mockReset() - }) - - it("returns the user's list unchanged when PROTECTED_PATHS is set", async () => { - const config = makeConfig({ protectedPathsOverride: ["Secrets", "Custom"] }) - const result = await resolveEffectiveProtectedPaths({ config, vaultPath: "/vault" }, logger) - - expect(result).toEqual(["Secrets", "Custom"]) - expect(mockedReadDailyNotesFileConfig).not.toHaveBeenCalled() - }) - - it("protects the memory dir plus the file-configured daily folder by default", async () => { - mockedReadDailyNotesFileConfig.mockResolvedValue({ - folder: "Journal", - format: "YYYY-MM-DD", - }) - const requestLogger = logger.child({ requestId: "request-1" }) - const config = makeConfig() - const result = await resolveEffectiveProtectedPaths( - { config, vaultPath: "/vault" }, - requestLogger, - ) - - expect(result).toEqual(["About Me", "Journal"]) - expect(mockedReadDailyNotesFileConfig).toHaveBeenCalledTimes(1) - expect(mockedReadDailyNotesFileConfig).toHaveBeenCalledWith("/vault", requestLogger) - }) - - it("uses the configured memory dir in the default set", async () => { - mockedReadDailyNotesFileConfig.mockResolvedValue({ - folder: "Daily Notes", - format: "YYYY-MM-DD", - }) - const config = makeConfig({ memoryDir: "Profile" }) - const result = await resolveEffectiveProtectedPaths({ config, vaultPath: "/vault" }, logger) - - expect(result).toEqual(["Profile", "Daily Notes"]) - }) - - it("protects only the memory dir when the resolved daily folder is blank", async () => { - mockedReadDailyNotesFileConfig.mockResolvedValue({ - folder: " ", - format: "YYYY-MM-DD", - }) - const config = makeConfig() - const result = await resolveEffectiveProtectedPaths({ config, vaultPath: "/vault" }, logger) - - expect(result).toEqual(["About Me"]) - }) - - it("protects DAILY_NOTES_FOLDER without reading the file, even when the format is unset", async () => { - const config = makeConfig({ dailyNotesFolder: "Journal" }) - const result = await resolveEffectiveProtectedPaths({ config, vaultPath: "/vault" }, logger) - - expect(result).toEqual(["About Me", "Journal"]) - expect(mockedReadDailyNotesFileConfig).not.toHaveBeenCalled() - }) - - it("reads the file for the folder when only DAILY_NOTES_FORMAT is set", async () => { - mockedReadDailyNotesFileConfig.mockResolvedValue({ - folder: "Journal", - format: "YYYY-MM-DD", - }) - const config = makeConfig({ dailyNotesFormat: "DD-MM-YYYY" }) - const result = await resolveEffectiveProtectedPaths({ config, vaultPath: "/vault" }, logger) - - expect(result).toEqual(["About Me", "Journal"]) - expect(mockedReadDailyNotesFileConfig).toHaveBeenCalledTimes(1) - }) - - it("rejects when the file exists but cannot be read, instead of protecting the default folder", async () => { - mockedReadDailyNotesFileConfig.mockRejectedValue( - new Error("cannot read daily notes config from .obsidian/daily-notes.json"), - ) - const config = makeConfig() - - await expect( - resolveEffectiveProtectedPaths({ config, vaultPath: "/vault" }, logger), - ).rejects.toThrow(new Error("cannot read daily notes config from .obsidian/daily-notes.json")) - }) -}) diff --git a/src/vault-mcp/mcp-core/tools/search-tools.ts b/src/vault-mcp/mcp-core/tools/search-tools.ts index 695f2fb1a..6cc87cab6 100644 --- a/src/vault-mcp/mcp-core/tools/search-tools.ts +++ b/src/vault-mcp/mcp-core/tools/search-tools.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { TOOL_NAMES } from "../tool-registry.js" import type { ToolRegistrationContext } from "./tool-helpers.js" +import { readEffectiveOrphanExcludeFolders } from "../../vault-operations/vault-folder-config.js" import { readDailyNotesConfig } from "../../vault-operations/daily-notes.js" import { safeHandler, formatNoteMetadata, dateFilterSchema } from "./tool-helpers.js" @@ -610,30 +611,40 @@ Errors: }, ) + const orphanDefaultFolders = config.orphanExcludeFoldersOverride + ? JSON.stringify(config.orphanExcludeFoldersOverride) + : `daily notes folder, Templates, ${JSON.stringify(config.memoryDir)}` + const orphanDefaultDescription = config.orphanExcludeFoldersOverride + ? "With exclude_folders omitted, the ORPHAN_EXCLUDE_FOLDERS override is used." + : 'The daily notes folder is resolved on each call (DAILY_NOTES_FOLDER → .obsidian/daily-notes.json → "Daily Notes"). ORPHAN_EXCLUDE_FOLDERS replaces the defaults. For unreadable daily settings, the server logs a warning and uses "Daily Notes".' + registerTool( TOOL_NAMES.VAULT_FIND_ORPHANS, { title: "Find Orphans", - description: `Find notes with no incoming links from other notes — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored). + description: `Find notes with no incoming links from other notes or canvases — orphans are disconnected from the knowledge graph and may be forgotten or need linking. A note that only links to itself still counts as an orphan (self-links are ignored). -Example: vault_find_orphans({ exclude_folders: ${JSON.stringify(config.orphanExcludeFolders)} }) +Example: vault_find_orphans({}) +Example: vault_find_orphans({ exclude_folders: ["Archive"], limit: 10 }) When to use: Vault maintenance — surfacing notes to integrate into the graph.${whenToolEnabledText("vault_patch_note", " Link an orphan by mentioning it from a relevant note with vault_patch_note.")} Prefer vault_get_backlinks to check the connectivity of one specific note rather than scanning the whole vault. Parameters: -- exclude_folders replaces the defaults, it does not add to them — include the defaults yourself to keep them. Each entry names a whole folder, subfolders included ("Projects" also excludes "Projects/Archive" but not "ProjectsOld/"), ignoring ASCII letter case. -- limit (default 50) applies after sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check. +- ${orphanDefaultDescription} +- exclude_folders replaces the defaults (including an environment override), it does not add to them — include the defaults yourself to keep them. Pass [] for no exclusions. Each entry names a whole folder, subfolders included ("Projects" also excludes "Projects/Archive" but not "ProjectsOld/"), ignoring ASCII letter case. +- limit applies after exclusions and sorting by most recently modified. Nothing in the response signals truncation: exactly limit results may mean more exist, so raise limit to check. Errors: - An empty array means no orphans were found (after exclusions), not an error. +- "too many excluded folders" — pass a shorter exclude_folders list, then retry. Returns: JSON array of note metadata (path, title, tags, related, folder, type, created, modified, bytes, leading_callout?, additional_properties?), sorted by most recently modified. bytes is the on-disk file size.`, inputSchema: { exclude_folders: z .array(z.string().min(1)) .optional() - .describe(`Folders to exclude (default ${JSON.stringify(config.orphanExcludeFolders)})`), + .describe(`Folder paths to exclude (e.g. Projects; default: ${orphanDefaultFolders})`), limit: z.number().int().min(1).optional().default(50).describe("Max results (default 50)"), }, }, @@ -646,9 +657,12 @@ Returns: JSON array of note metadata (path, title, tags, related, folder, type, return safeHandler( reqLogger, async () => { + const excludeFolders = + exclude_folders ?? + (await readEffectiveOrphanExcludeFolders({ config, vaultPath }, reqLogger)) return search.findOrphans( { - excludeFolders: exclude_folders ?? [...config.orphanExcludeFolders], + excludeFolders: [...excludeFolders], limit, }, reqLogger, 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 afdaf0a1a..8b1747280 100644 --- a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts +++ b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts @@ -2,10 +2,9 @@ import { z } from "zod" import type { VaultConfig } from "../../config.js" -import type { Logger } from "../../../logger.js" import { vaultFs, resolveVaultRelativePath } from "../../vault-operations/vault-filesystem.js" import { noteMover } from "../../vault-operations/note-mover.js" -import { readDailyNotesFileConfig } from "../../vault-operations/daily-notes.js" +import { resolveEffectiveProtectedPaths } from "../../vault-operations/vault-folder-config.js" import { readTrashConfig } from "../../vault-operations/trash-config.js" import { vaultPatcher } from "../../vault-operations/vault-patcher.js" import type { DisplacedLeadingContent } from "../../vault-operations/vault-patcher.js" @@ -30,32 +29,6 @@ const describeDisplacedLeadingContent = ({ return `The ${bytes} bytes of pre-existing content above the note's first heading are now nested under the inserted heading. To add a section above the first heading without pulling existing content into it, use operation "insert_before" with heading "${firstHeading.text}" (H${firstHeading.level}).` } -/** The folders that delete and move refuse to touch. - * - PROTECTED_PATHS, when set, replaces the defaults entirely, so only the - * folders it lists are protected. - * - Otherwise the memory dir (protected even when MEMORY_ENABLED is false) - * 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. - * 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. */ -export const resolveEffectiveProtectedPaths = async ( - { config, vaultPath }: { config: VaultConfig; vaultPath: string }, - logger: Logger, -): 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] - - const dailyNotesConfig = await readDailyNotesFileConfig(vaultPath, logger) - - // A whitespace-only folder in daily-notes.json protects nothing. - const dailyFolder = dailyNotesConfig.folder.trim() - return dailyFolder ? [config.memoryDir, dailyFolder] : [config.memoryDir] -} - /** Protected-path list for tool descriptions. Descriptions are built once at * registration and the daily notes folder is resolved per call, so the text * names that folder's sources, not its value ("Daily Notes" restates the diff --git a/src/vault-mcp/search/__tests__/search-index.test.ts b/src/vault-mcp/search/__tests__/search-index.test.ts index ae4eab18a..740b379c2 100644 --- a/src/vault-mcp/search/__tests__/search-index.test.ts +++ b/src/vault-mcp/search/__tests__/search-index.test.ts @@ -3688,28 +3688,42 @@ describe("getOutgoingLinks", () => { expect(broken?.bytes).toBeNull() }) - it("flags daily note forward-refs when the folder is passed", () => { - index.upsertNote( - { - filePath: "Daily Notes/2026-06-24.md", - rawContent: "# 2026-06-24\n\n[[Daily Notes/2026-06-25|Tomorrow >>]] and [[missing]].\n", - fileStat: testStat(1000), - }, - logger, - ) - - const links = index.getOutgoingLinks( - { path: "Daily Notes/2026-06-24.md", dailyNotesFolder: "Daily Notes" }, - logger, - ) - const forwardRef = links.find((link) => link.path === "Daily Notes/2026-06-25") - expect(forwardRef?.exists).toBe(false) - expect(forwardRef?.daily_note_forward_ref).toBe(true) + it.each(["Daily Notes", "Daily Notes/", "Daily Notes///"])( + "flags daily note forward-refs with folder %s", + (dailyNotesFolder) => { + index.upsertNote( + { + filePath: "Daily Notes/2026-06-24.md", + rawContent: "# 2026-06-24\n\n[[Daily Notes/2026-06-25|Tomorrow >>]] and [[missing]].\n", + fileStat: testStat(1000), + }, + logger, + ) - const genuinelyBroken = links.find((link) => link.path === "missing") - expect(genuinelyBroken?.exists).toBe(false) - expect(genuinelyBroken?.daily_note_forward_ref).toBe(false) - }) + const links = index.getOutgoingLinks( + { path: "Daily Notes/2026-06-24.md", dailyNotesFolder }, + logger, + ) + expect(links).toEqual([ + { + path: "Daily Notes/2026-06-25", + title: null, + exists: false, + kind: "note", + bytes: null, + daily_note_forward_ref: true, + }, + { + path: "missing", + title: null, + exists: false, + kind: "note", + bytes: null, + daily_note_forward_ref: false, + }, + ]) + }, + ) it("returns empty for notes with no outgoing links", () => { index.upsertNote( @@ -3768,6 +3782,61 @@ describe("findOrphans", () => { expect(orphanPaths).toContain("Projects/orphan.md") }) + it("translates oversized exclusions while retaining and logging the SQLite diagnostic", () => { + const queryIndex = createSearchIndex(":memory:") + const requestLogger = { ...logger, warn: vi.fn() } + const excludeFolders = Array.from({ length: 1000 }, (_, folderIndex) => `Folder${folderIndex}`) + const queryError = (() => { + try { + queryIndex.findOrphans({ excludeFolders }, requestLogger) + } catch (error) { + return error + } + throw new Error("expected the orphan query to fail") + })() + + if (!(queryError instanceof Error) || !(queryError.cause instanceof Database.SqliteError)) { + throw new Error("expected a domain error with the original SQLite cause") + } + expect({ + message: queryError.message, + causeName: queryError.cause.name, + causeCode: queryError.cause.code, + causeMessage: queryError.cause.message, + }).toEqual({ + message: "too many excluded folders", + causeName: "SqliteError", + causeCode: "SQLITE_ERROR", + causeMessage: "Expression tree is too large (maximum depth 1000)", + }) + expect(requestLogger.warn).toHaveBeenCalledTimes(1) + expect(requestLogger.warn).toHaveBeenCalledWith("orphan exclusion query capacity exceeded", { + excludedFolderCount: 1000, + error: "[SqliteError]: Expression tree is too large (maximum depth 1000)", + }) + }) + + it("propagates unrelated SQLite query failures unchanged", () => { + const queryIndex = createSearchIndex(":memory:") + const requestLogger = { ...logger, warn: vi.fn() } + const sqliteError = new Database.SqliteError("no such table: notes", "SQLITE_ERROR") + const prepareSpy = vi.spyOn(Database.prototype, "prepare").mockImplementationOnce(() => { + throw sqliteError + }) + onTestFinished(() => prepareSpy.mockRestore()) + const queryError = (() => { + try { + queryIndex.findOrphans({}, requestLogger) + } catch (error) { + return error + } + throw new Error("expected the orphan query to fail") + })() + + expect(queryError).toBe(sqliteError) + expect(requestLogger.warn).not.toHaveBeenCalled() + }) + it("excludes connected notes", () => { const orphans = index.findOrphans({}, logger) const orphanPaths = orphans.map((orphan) => orphan.path) @@ -4284,18 +4353,25 @@ describe("brokenLinkCount", () => { expect(outgoing[0]?.kind).toBe("note") }) - it("excludes forward-reference links that are valid dates under the daily note folder", () => { - index.upsertNote( - { - filePath: "Daily Notes/2026-06-24.md", - rawContent: - "# 2026-06-24\n\n[[Daily Notes/2026-06-25|Tomorrow >>]] and [[missing-note]].\n", - fileStat: testStat(1000), - }, - logger, - ) - expect(index.brokenLinkCount({ dailyNotesFolder: "Daily Notes" }, logger).count).toBe(1) - }) + it.each(["Daily Notes", "Daily Notes/", "Daily Notes///"])( + "excludes daily forward references with folder %s and retains sibling-folder failures", + (dailyNotesFolder) => { + index.upsertNote( + { + filePath: "Daily Notes/2026-06-24.md", + rawContent: + "# 2026-06-24\n\n[[Daily Notes/2026-06-25|Tomorrow >>]] and [[missing-note]] and [[Daily Notes Extra/missing]].\n", + fileStat: testStat(1000), + }, + logger, + ) + expect(index.brokenLinkCount({ dailyNotesFolder }, logger)).toEqual({ + count: 2, + excludedFolder: dailyNotesFolder, + excludedCount: 1, + }) + }, + ) it("excludes .md-suffixed forward-reference targets", () => { index.upsertNote( diff --git a/src/vault-mcp/search/search-queries.ts b/src/vault-mcp/search/search-queries.ts index 72af0832b..364c536d3 100644 --- a/src/vault-mcp/search/search-queries.ts +++ b/src/vault-mcp/search/search-queries.ts @@ -1458,7 +1458,9 @@ export const getOutgoingLinks = ( } >(sql) .all(params.path) - const dailyNotesFolderPrefix = params.dailyNotesFolder ? `${params.dailyNotesFolder}/` : null + const dailyNotesFolderPrefix = params.dailyNotesFolder + ? `${stripTrailingSlashes(params.dailyNotesFolder)}/` + : null const results: OutgoingLinkEntry[] = rows.map((row) => ({ path: row.path, title: row.title, @@ -1506,7 +1508,25 @@ export const findOrphans = ( LIMIT ? ` - const rows = context.db.prepare(sql).all(...escapedExcludeFolders, limit) + const rows = (() => { + try { + return context.db.prepare(sql).all(...escapedExcludeFolders, limit) + } catch (error) { + const isExclusionCapacityError = + error instanceof Error && + "code" in error && + error.code === "SQLITE_ERROR" && + error.message.startsWith("Expression tree is too large (maximum depth ") + + if (!isExclusionCapacityError) throw error + + logger.warn("orphan exclusion query capacity exceeded", { + excludedFolderCount: excludeFolders.length, + error: describeError(error), + }) + throw new Error("too many excluded folders", { cause: error }) + } + })() const results = rows.map(rowToMetadata) logger.info("find orphans", { count: results.length }) return results @@ -1550,7 +1570,7 @@ export const brokenLinkCount = ( return { count, excludedFolder: null, excludedCount: 0 } } - const excludedFolderPrefix = `${excludedFolder}/` + const excludedFolderPrefix = `${stripTrailingSlashes(excludedFolder)}/` const brokenTargets = context.db .prepare( `SELECT DISTINCT target 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 new file mode 100644 index 000000000..8889d9335 --- /dev/null +++ b/src/vault-mcp/vault-operations/__tests__/vault-folder-config.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest" +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { loadConfig } from "../../config.js" +import { logger } from "../../../logger.js" +import { vaultFs } from "../vault-filesystem.js" +import { + readEffectiveOrphanExcludeFolders, + resolveEffectiveOrphanExcludeFolders, + resolveEffectiveProtectedPaths, +} from "../vault-folder-config.js" + +const createVault = async (settings?: string): Promise => { + const vaultPath = await mkdtemp(join(tmpdir(), "vault-folder-config-")) + onTestFinished(() => rm(vaultPath, { recursive: true, force: true })) + await mkdir(join(vaultPath, ".obsidian")) + + if (settings !== undefined) { + await writeFile(join(vaultPath, ".obsidian/daily-notes.json"), settings) + } + return vaultPath +} + +describe("resolveEffectiveProtectedPaths", () => { + it("returns the user's list unchanged without reading malformed daily settings", async () => { + const vaultPath = await createVault("broken") + const requestLogger = { ...logger, warn: vi.fn() } + const config = loadConfig({ PROTECTED_PATHS: "Secrets,Custom" }) + + expect(await resolveEffectiveProtectedPaths({ config, vaultPath }, requestLogger)).toEqual([ + "Secrets", + "Custom", + ]) + expect(requestLogger.warn).not.toHaveBeenCalled() + }) + + it("protects the memory dir plus the file-configured daily folder", async () => { + const vaultPath = await createVault('{"folder":"Journal"}') + expect( + await resolveEffectiveProtectedPaths({ config: loadConfig({}), vaultPath }, logger), + ).toEqual(["About Me", "Journal"]) + }) + + it("protects custom memory even when the memory feature is disabled", async () => { + const vaultPath = await createVault() + const config = loadConfig({ MEMORY_DIR: "Profile", MEMORY_ENABLED: "false" }) + expect(await resolveEffectiveProtectedPaths({ config, vaultPath }, logger)).toEqual([ + "Profile", + "Daily Notes", + ]) + }) + + it("protects only memory when the file folder is whitespace-only", async () => { + const vaultPath = await createVault('{"folder":" "}') + 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( + await resolveEffectiveProtectedPaths({ config: loadConfig({}), vaultPath }, logger), + ).toEqual(["About Me", " Journal "]) + }) + + it("refuses deletion under a file-configured folder with repeated trailing separators", async () => { + const vaultPath = await createVault('{"folder":" Journal ///"}') + await mkdir(join(vaultPath, " Journal ")) + await writeFile(join(vaultPath, " Journal /daily.md"), "daily body\n") + const protectedPaths = await resolveEffectiveProtectedPaths( + { config: loadConfig({}), vaultPath }, + logger, + ) + + await expect( + vaultFs.deleteNote( + { + vaultPath, + path: " Journal /daily.md", + protectedPaths, + pruneEmptyFolders: false, + trashOption: "local", + }, + logger, + ), + ).rejects.toThrow(new Error('cannot delete protected path " Journal /daily.md"')) + expect(await readFile(join(vaultPath, " Journal /daily.md"), "utf8")).toBe("daily body\n") + }) + + it("uses DAILY_NOTES_FOLDER without reading the file when the format is unset", async () => { + const vaultPath = await createVault("broken") + const requestLogger = { ...logger, warn: vi.fn() } + const config = loadConfig({ DAILY_NOTES_FOLDER: "Journal" }) + expect(await resolveEffectiveProtectedPaths({ config, vaultPath }, requestLogger)).toEqual([ + "About Me", + "Journal", + ]) + expect(requestLogger.warn).not.toHaveBeenCalled() + }) + + 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" }) + expect(await resolveEffectiveProtectedPaths({ config, vaultPath }, logger)).toEqual([ + "About Me", + "Journal", + ]) + }) + + it("rejects malformed existing settings and propagates the caller's logger", async () => { + const vaultPath = await createVault("broken") + const requestLogger = { ...logger.child({ requestId: "folder-request" }), warn: vi.fn() } + await expect( + resolveEffectiveProtectedPaths({ config: loadConfig({}), vaultPath }, requestLogger), + ).rejects.toThrow(new Error("cannot read daily notes config from .obsidian/daily-notes.json")) + expect(requestLogger.warn).toHaveBeenCalledTimes(1) + expect(requestLogger.warn).toHaveBeenCalledWith("cannot read daily notes config", { + error: expect.any(String), + }) + }) +}) + +describe("resolveEffectiveOrphanExcludeFolders", () => { + it("keeps nonblank file spelling and default folder order", () => { + expect( + resolveEffectiveOrphanExcludeFolders({ + orphanExcludeFoldersOverride: null, + memoryDir: "Profile", + dailyNotesFolder: " Journal/Daily/ ", + }), + ).toEqual([" Journal/Daily/ ", "Templates", "Profile"]) + }) + + it("omits a whitespace-only daily folder", () => { + expect( + resolveEffectiveOrphanExcludeFolders({ + orphanExcludeFoldersOverride: null, + memoryDir: "About Me", + dailyNotesFolder: " ", + }), + ).toEqual(["Templates", "About Me"]) + }) + + it("preserves an explicit empty list", () => { + expect( + resolveEffectiveOrphanExcludeFolders({ + orphanExcludeFoldersOverride: [], + memoryDir: "About Me", + dailyNotesFolder: "Journal", + }), + ).toEqual([]) + }) +}) + +describe("readEffectiveOrphanExcludeFolders", () => { + it.each([ + { + label: "missing settings", + settings: undefined, + env: {}, + expected: ["Daily Notes", "Templates", "About Me"], + }, + { + label: "file-only nested folder", + settings: '{"folder":"Planner/Daily"}', + env: {}, + expected: ["Planner/Daily", "Templates", "About Me"], + }, + { + label: "custom memory with memory disabled", + settings: undefined, + env: { MEMORY_DIR: "Profile", MEMORY_ENABLED: "false" }, + expected: ["Daily Notes", "Templates", "Profile"], + }, + { + label: "format-only env", + settings: '{"folder":"Journal"}', + env: { DAILY_NOTES_FORMAT: "DD-MM-YYYY" }, + expected: ["Journal", "Templates", "About Me"], + }, + { + label: "blank file folder", + settings: '{"folder":" "}', + env: {}, + expected: ["Templates", "About Me"], + }, + ])("uses $label", async ({ settings, env, expected }) => { + const vaultPath = await createVault(settings) + expect( + await readEffectiveOrphanExcludeFolders({ config: loadConfig(env), vaultPath }, logger), + ).toEqual(expected) + }) + + it.each([ + { + label: "daily folder override", + env: { DAILY_NOTES_FOLDER: "Env/Journal" }, + expected: ["Env/Journal", "Templates", "About Me"], + }, + { + label: "explicit orphan override", + env: { ORPHAN_EXCLUDE_FOLDERS: "Archive,Scratch" }, + expected: ["Archive", "Scratch"], + }, + { label: "comma-only empty override", env: { ORPHAN_EXCLUDE_FOLDERS: ", ," }, expected: [] }, + ])("bypasses malformed settings for $label", async ({ env, expected }) => { + const vaultPath = await createVault("broken") + const requestLogger = { ...logger, warn: vi.fn() } + expect( + await readEffectiveOrphanExcludeFolders( + { config: loadConfig(env), vaultPath }, + requestLogger, + ), + ).toEqual(expected) + expect(requestLogger.warn).not.toHaveBeenCalled() + }) + + it("warns and falls back when existing settings are malformed", async () => { + const vaultPath = await createVault("broken") + const requestLogger = { ...logger, warn: vi.fn() } + expect( + await readEffectiveOrphanExcludeFolders({ config: loadConfig({}), vaultPath }, requestLogger), + ).toEqual(["Daily Notes", "Templates", "About Me"]) + expect(requestLogger.warn).toHaveBeenCalledTimes(1) + expect(requestLogger.warn).toHaveBeenCalledWith( + "cannot read daily notes config, using defaults", + { + error: expect.any(String), + }, + ) + }) +}) diff --git a/src/vault-mcp/vault-operations/vault-folder-config.ts b/src/vault-mcp/vault-operations/vault-folder-config.ts new file mode 100644 index 000000000..6aa86e4c8 --- /dev/null +++ b/src/vault-mcp/vault-operations/vault-folder-config.ts @@ -0,0 +1,69 @@ +import type { VaultConfig } from "../config.js" +import type { Logger } from "../../logger.js" +import { readDailyNotesConfig, readDailyNotesFileConfig } from "./daily-notes.js" + +/** The folders that delete and move refuse to touch. + * - PROTECTED_PATHS, when set, replaces the defaults entirely, so only the + * folders it lists are protected. + * - Otherwise the memory dir (protected even when MEMORY_ENABLED is false) + * 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. + * 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. */ +export const resolveEffectiveProtectedPaths = async ( + { config, vaultPath }: { config: VaultConfig; vaultPath: string }, + logger: Logger, +): 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] + + const dailyNotesConfig = await readDailyNotesFileConfig(vaultPath, logger) + + // A whitespace-only folder in daily-notes.json protects nothing. + const dailyFolder = dailyNotesConfig.folder.replace(/\/+$/, "") + return dailyFolder.trim() ? [config.memoryDir, dailyFolder] : [config.memoryDir] +} + +export const resolveEffectiveOrphanExcludeFolders = ({ + orphanExcludeFoldersOverride, + memoryDir, + dailyNotesFolder, +}: { + orphanExcludeFoldersOverride: readonly string[] | null + memoryDir: string + dailyNotesFolder: string +}): 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] +} + +/** Only the daily folder affects orphan exclusions, so its env override skips file I/O. */ +export const readEffectiveOrphanExcludeFolders = async ( + { config, vaultPath }: { config: VaultConfig; vaultPath: string }, + logger: Logger, +): Promise => { + if (config.orphanExcludeFoldersOverride) return config.orphanExcludeFoldersOverride + + if (config.dailyNotesFolder) { + return resolveEffectiveOrphanExcludeFolders({ + orphanExcludeFoldersOverride: null, + memoryDir: config.memoryDir, + dailyNotesFolder: config.dailyNotesFolder, + }) + } + + const dailyNotesConfig = await readDailyNotesConfig({ vaultPath }, logger) + return resolveEffectiveOrphanExcludeFolders({ + orphanExcludeFoldersOverride: null, + memoryDir: config.memoryDir, + dailyNotesFolder: dailyNotesConfig.folder, + }) +}