diff --git a/.devin/wiki.json b/.devin/wiki.json index c0619dd64..f119431f3 100644 --- a/.devin/wiki.json +++ b/.devin/wiki.json @@ -40,7 +40,7 @@ }, { "title": "Obsidian Markdown Parsing", - "purpose": "The pure parser layer in obsidian-markdown/ — no filesystem I/O: links.ts, lines.ts, frontmatter.ts, tags.ts, body-tags.ts with obsidian-remark-parser.ts and remark-ports/ (body tags on remark-parse 8, the parser Obsidian forked), callouts.ts, headings.ts, tasks.ts, recurrence.ts, memory-entries.ts, canvas.ts, plaintext.ts, pdf.ts, pdf-engine.ts, moment-format.ts. This layer encodes the Obsidian-parity behavior (every link form and 'New link format' mode Obsidian recognizes; both Tasks-plugin formats) that distinguishes Vault Cortex from simpler file-based integrations.", + "purpose": "The pure parser layer in obsidian-markdown/: links.ts, lines.ts, frontmatter.ts, tags.ts, body-tags.ts with obsidian-remark-parser.ts and remark-ports/ (body tags on remark-parse 8, the parser Obsidian forked), callouts.ts, headings.ts, tasks.ts, recurrence.ts, memory-entries.ts, canvas.ts, file-kinds.ts, html.ts, plaintext.ts, pdf.ts, pdf-engine.ts, moment-format.ts. None does filesystem I/O except pdf-engine.ts, which resolves pdfjs package paths from disk. This layer encodes the Obsidian-parity behavior (every link form and 'New link format' mode Obsidian recognizes; both Tasks-plugin formats) that distinguishes Vault Cortex from simpler file-based integrations.", "parent": "Architecture" }, { @@ -65,7 +65,7 @@ }, { "title": "Non-Markdown Files", - "purpose": "How non-markdown vault files (images, PDFs, canvases, text and data files) are read, browsed, and searched. Modules: asset-operations.ts (the use-case layer behind the file tools); canvas.ts (JSON Canvas to readable markdown); fit-image-to-byte-budget.ts (image byte-budget fitting); obsidian-markdown/pdf-engine.ts (the pdfjs bootstrap); obsidian-markdown/pdf.ts (PDF text extraction); the file_content and file_content_fts tables in the search index. Derive supported formats and size limits from the source at index time.", + "purpose": "How non-markdown vault files (images, PDFs, canvases, text and data files, HTML pages, scripts) are read, browsed, and searched. Modules: asset-operations.ts (the use-case layer behind the file tools); obsidian-markdown/file-kinds.ts (extension to file kind); utils/decode-text-buffer.ts (the UTF-8 text check reads and indexing share); obsidian-markdown/html.ts (HTML visible text); canvas.ts (JSON Canvas to readable markdown); fit-image-to-byte-budget.ts (image byte-budget fitting); obsidian-markdown/pdf-engine.ts (the pdfjs bootstrap); obsidian-markdown/pdf.ts (PDF text extraction); the file_content and file_content_fts tables in the search index. Derive supported formats and size limits from the source at index time.", "parent": "Architecture" }, { diff --git a/.env.example b/.env.example index c867b4beb..2b0f2ae0a 100644 --- a/.env.example +++ b/.env.example @@ -66,8 +66,8 @@ GHCR_USER= # Folders to leave out of sync, comma-separated (default: none excluded). # SYNC_EXCLUDED_FOLDERS= # Attachment types to sync, comma-separated: image | audio | video | pdf | unsupported (default: image,audio,video,pdf). -# To read CSV, JSON, TXT, XML, LOG, and YAML, set image,audio,video,pdf,unsupported (the value replaces the -# default list) and turn on "Sync all other types" on the uploading device. +# To read text files such as CSV, JSON, TXT, HTML and scripts, set image,audio,video,pdf,unsupported (the +# value replaces the default list) and turn on "Sync all other types" on the uploading device. # SYNC_FILE_TYPES= # Obsidian settings categories to sync into the server's .obsidian/ folder # (default: daily notes settings, community plugin settings such as the diff --git a/AGENTS.md b/AGENTS.md index 5bb78b468..bfb75a00b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ src/ __tests__/docker/ # :remote image boot tests (npm run test:remote-boot): docker-harness, and fixtures/ob, a stub of the obsidian-headless CLI vault-mcp/ server.ts, config.ts # Entry point and env-var loader, the only loose files here - obsidian-markdown/ # Pure parsers and transforms with no I/O (pdf-engine excepted): lines, frontmatter, callouts, headings, links, tags, body-tags + obsidian-remark-parser + remark-ports/, tasks, recurrence, memory-entries, canvas, plaintext, pdf + pdf-engine, moment-format + obsidian-markdown/ # Pure parsers and transforms with no I/O (pdf-engine excepted): lines, frontmatter, callouts, headings, links, tags, body-tags + obsidian-remark-parser + remark-ports/, tasks, recurrence, memory-entries, canvas, file-kinds, html, plaintext, pdf + pdf-engine, moment-format vault-operations/ # Vault read, write, and patch: vault-filesystem (base I/O), vault-patcher, note-mover, memory-store, daily-notes, vault-folder-config, task-mutations, task-format-config, trash-config, trash-sweeper, asset-operations mcp-core/ # MCP protocol surface: mcp-router, tool-registry, tool-availability, tool-definitions, prompt-definitions, coded-error-remedy tools/ # One -tools module per data-layer domain (vault-crud, search, task, memory, daily-note, asset), plus tool-helpers diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 600f6f82b..1d68ccb2e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -345,11 +345,11 @@ Link queries use a `links` table populated during indexing: | `vault_read_file` | `path, raw?, start_line?, limit?` | readOnlyHint | | `vault_list_files` | `folder?, extensions?, limit?` | readOnlyHint | -`vault_read_file` reads non-markdown vault files, dispatching on extension to the most useful representation per type: +`vault_read_file` reads non-markdown vault files, dispatching on the file's kind to the most useful representation per type. `obsidian-markdown/file-kinds.ts` maps the lowercased extension to a kind, so `Report.PDF` is a PDF. Reads, indexing, the note mover and the link tools take every non-markdown file's kind from it: 1. **Images** (`.png`/`.jpg`/`.jpeg`/`.gif`/`.webp`) return an MCP `image` content block plus a one-line metadata text block: the path, the delivered format, dimensions and bytes, and, when the image was recompressed, its original dimensions and bytes. A shared fit-to-byte-budget pipeline (`utils/fit-image-to-byte-budget.ts`, built on sharp) makes oversized images deliverable: EXIF auto-orient → resize long edge to ≤1568px → walk a fixed quality ladder (JPEG via mozjpeg for opaque images, WebP for alpha — PNG has no quality knob) → shrink dimensions by √(budget/actual) if the ladder floor still exceeds the budget. Deterministic and terminating (bounded attempts, 64px floor); sharp's default `limitInputPixels` stays active as the decompression-bomb guard. The budget (`MAX_IMAGE_OUTPUT_BYTES`, default 48 KiB binary) is sized for the tightest mainstream client cap. 2. **Canvas** (`.canvas`) linearizes to markdown via the pure `obsidian-markdown/canvas.ts` parser ([JSON Canvas 1.0](https://jsoncanvas.org)): group membership by spatial rect containment (innermost group wins; equal rects tiebreak deterministically by id), nodes in reading order (y, then x), and an edge list with node ids resolved to display names. Lenient parsing — unknown properties ignored, malformed entries skipped. `raw: true` skips the linearizer and returns the JSON source verbatim for full structural fidelity. Canvas content is also FTS-indexed (linearized text in `file_content` + `file_content_fts` tables, gated behind `FILE_TOOLS_ENABLED`) so canvas files appear in `vault_search` results with `kind: "file"`. Canvas `file`-type node references are extracted unconditionally into the `links` table for graph queries regardless of the feature gate. -3. **Text formats** (`.svg`/`.json`/`.txt`/`.csv`/`.xml`/`.log`/`.yaml`/`.yml`/`.base`) pass through verbatim as text, capped at a fixed 100 KiB output size (explicit error over silent truncation). `start_line`/`limit` page any text result — passthrough formats, canvas renditions, PDF-extracted text — as a 1-based line window preceded by a window-metadata block stating the range, total line count, and next `start_line`; the cap applies to the window, and an unpaged read stays byte-exact. +3. **Text formats** (`.svg`/`.json`/`.txt`/`.csv`/`.xml`/`.log`/`.yaml`/`.yml`/`.base`) pass through as text, without a leading byte-order mark, capped at a fixed 100 KiB output size (explicit error over silent truncation). They skip the NUL-byte check in item 5, so a NUL-padded log still reads. `start_line`/`limit` page any text result — text files, canvas renditions, PDF-extracted text — as a 1-based line window preceded by a window-metadata block stating the range, total line count, and next `start_line`; the cap applies to the window, and an unpaged read is byte-exact apart from that mark. 4. **PDFs** (`.pdf`) return structured markdown reconstructed from layout-aware extraction (`unpdf`, based on Mozilla's PDF.js): - A document metadata header (title, page count, link count) - Heading hierarchy inferred from font sizes relative to the dominant @@ -364,11 +364,27 @@ Link queries use a `links` table populated during indexing: **Why text works without system fonts:** every PDF read goes through `obsidian-markdown/pdf-engine.ts`, which swaps unpdf's bundled edge build for the `pdfjs-dist` legacy Node build and creates document proxies with `disableFontFace` + bundled standard fonts + cMaps. Glyphs render from font data (embedded or bundled), never from host system fonts, so text survives in the fontless container. unpdf's own defaults reach the bundled fonts only if a lookup of `pdfjs-dist` succeeds, and a failed lookup is silent: rendering falls back to system fonts, which drops all text glyphs where no fonts exist while vector graphics still draw. -5. **Unknown types** return an error naming the readable set. +5. **Every other file** — HTML pages, scripts, and files with an unlisted extension or none — returns its source as text, with the same cap and paging. `utils/decode-text-buffer.ts` refuses two cases: + - a NUL byte in the first 8,000 bytes, git's test for binary data, which also catches UTF-16 text; the error names the readable types + - bytes that are not UTF-8 The extension-to-representation routing above is implemented by the `vault-operations/asset-operations.ts` use-case. Beneath it, every read goes through `vaultFs.readAsset`, which applies the same `resolveSafePath` traversal + hidden-path guards as notes, rejects `.md` paths (notes belong to `vault_read_note`), and enforces a stat-before-read size cap (`MAX_FILE_BYTES`, default 50 MiB). The read buffer is one byte longer than the statted size, so a file that grows during the read (an Obsidian Sync download can do this) fills that byte, and the read fails with "file changed while reading". -`vault_list_files` is the discovery surface (also `vault-operations/asset-operations.ts`): a filesystem walk (`vaultFs.listAssets` — filesystem truth, deliberately not the index), folder and case-insensitive extension filters, per-extension counts computed over the full filtered set, and byte sizes statted only for the returned slice. Canvas, PDF, and text files (.txt, .csv, .json, .xml, .svg, .log, .yaml, .yml, .base) are indexed for full-text search in the `file_content` + `file_content_fts` tables — see Canvas entry above for the canvas-specific link graph integration. PDF text is extracted via `obsidian-markdown/pdf.ts` (structured markdown reconstruction) — this works on PDFs with embedded text content; scanned or image-only PDFs have no text to extract, so they are indexed with an empty body and found by file name only. Text files are indexed as raw UTF-8 content. Content exceeding 100 KiB is truncated before FTS insertion. +`vault_list_files` is the discovery surface (also `vault-operations/asset-operations.ts`): a filesystem walk (`vaultFs.listAssets` — filesystem truth, deliberately not the index), folder and case-insensitive extension filters, per-extension counts computed over the full filtered set, and byte sizes statted only for the returned slice. + +Search indexes file content in the `file_content` + `file_content_fts` tables, cut to 100 KiB before FTS insertion. Each kind is indexed as follows: + +| Kind | Indexed text | Embedded | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------- | -------- | +| Canvas | Linearized outline; file cards also go to the link graph (see Canvas above) | Yes | +| PDF | Text extracted by `obsidian-markdown/pdf.ts`; a scanned or image-only PDF has none and is found by file name only | Yes | +| Text formats (item 3) | The file's first 100 KiB, which is all indexing reads; bytes that are not UTF-8 are replaced rather than refused | Yes | +| HTML (`.html`/`.htm`) | The text a browser shows, from `obsidian-markdown/html.ts`; a page over 16 MiB is found by file name only | Yes | +| Scripts (`.py`/`.sh`/`.ps1`) | The file's source; a script over 16 MiB is found by file name only | No | + +- **HTML extraction** runs htmlparser2's tokenizer, which keeps no element stack, so unclosed tags cost no more than closed ones. Hidden elements (script, style, noscript, template, the fallback content of iframe, noembed and noframes, and an inline SVG's title and desc tooltips) end only at a closing tag with their own name, so a stray closing tag never shows hidden text. Block elements become line breaks, other whitespace collapses, and reading stops at the 100 KiB cap. Extraction stays on the main thread, and the 16 MiB page limit keeps its measured worst case under 2 seconds. +- **HTML pages and scripts are read as bytes**, and their text is indexed only when `utils/decode-text-buffer.ts` accepts them, the test `vault_read_file` applies, so search never holds text from a page or script that a read refuses as binary or as not UTF-8. That test needs the whole file, so indexing reads a page or script whole and skips one whose size is over 16 MiB, which limits the memory one read takes. Search finds such a file by name, even when `vault_read_file` would refuse it. When a watched page or script stops passing that test, its stored content is removed and its file row stays. +- **Scripts are searched by keyword only.** The markdown chunker reads a script's `# comment` lines as headings and turns `snake_case` names into one word, so embedding skips scripts before their text loads. **Opt-out:** File tools are opt-out: set `FILE_TOOLS_ENABLED=false` to hide `vault_read_file` and `vault_list_files`, and strip file tool references from server metadata and other tool descriptions. The `vault_get_outgoing_links` tool continues to report file links (it indexes from the links table, not the file tools). File config vars (`MAX_FILE_BYTES`, `MAX_IMAGE_OUTPUT_BYTES`, `MAX_PDF_RENDER_PAGES`) are still parsed when disabled. @@ -460,7 +476,7 @@ Both models lazy-load on first use (~1–2s cold start each, kept in memory afte `vault_search` calls `hybridSearch`, which runs up to four ranked retrieval legs and merges results via RRF. The flow: 1. **Note FTS5** keyword search (synchronous, `fullTextSearch`) -2. **File content FTS5** — linearized canvas, extracted PDF text, plain text files (skipped when note-specific filters are active) +2. **File content FTS5** — linearized canvas, extracted PDF text, text files, HTML visible text, and scripts (skipped when note-specific filters are active) 3. **Note vector search** — embed the query → sqlite-vec KNN over `note_vectors` → deduplicate to best chunk per note 4. **File content vector search** — same query embedding → KNN over `file_content_vectors` → deduplicate to best chunk per file (same skip condition as step 2) 5. **RRF fusion** (`computeRrfScores`) — score = Σ(1/(k+rank)) across all available lists, k=60, with top-rank bonuses (+0.05 rank 1, +0.02 ranks 2–3): @@ -520,7 +536,7 @@ When no embedder is configured (`EMBEDDING_ENABLED=false`), no vectors are index **Indexing flow:** `rebuildFromVault` runs passes 1 and 2, then returns while pass 3 and the body-tag pass run in the background: 1. **Pass 1** — index notes (FTS5 + metadata), each with the body tags stored before the rebuild -2. **Pass 2** — extract links (with the complete path list for resolution), then index file content (canvas, PDF, text → FTS5) +2. **Pass 2** — extract links (with the complete path list for resolution), then index file content (canvas, PDF, text, HTML, scripts → FTS5) 3. **Pass 3 (background)** — embed notes, then file content. Until a note's vectors exist, search finds it by keyword only 4. **Body-tag pass (background)** — parse each note's body tags again in the body-tag worker thread and write the ones that changed (see **Body tags column**) @@ -563,7 +579,7 @@ Content-hash gating (SHA-256 per chunk) skips re-embedding unchanged content on - `note_chunks`: stores chunk text, position index, and content hash per note - `note_vectors` (vec0): stores 384-dim Float32 embeddings keyed by chunk ID -- `file_content_chunks`: stores chunk text, position index, and content hash per non-markdown file (canvas, PDF, text) +- `file_content_chunks`: stores chunk text, position index, and content hash per non-markdown file (canvas, PDF, text, HTML; scripts are never embedded) - `file_content_vectors` (vec0): stores 384-dim Float32 embeddings keyed by chunk ID **New-directory rescan:** chokidar handles a newly-appeared directory in two steps: first it scans the directory's contents, then it registers the directory's `fs.watch`. A file created between the scan and the registration is silently lost ([chokidar#1471](https://github.com/paulmillr/chokidar/issues/1471)) — the scan didn't see it, and no watch existed to catch the event. The server's atomic write into a freshly created folder can hit exactly that window, leaving the note invisible to search. As a safety net, the watcher schedules a one-shot rescan of every new directory, delayed to twice chokidar's write-stability threshold (`awaitWriteFinish`, 2 s — a 4 s delay) so in-flight writes settle first. The rescan: @@ -579,7 +595,7 @@ flowchart TD VF[Vault Files] --> RB[rebuildFromVault] RB --> P1[Pass 1: Index Notes\nFTS5 + metadata] P1 --> P2[Pass 2: Extract Links\nresolve with full path list] - P2 --> FC[Index File Content\ncanvas + PDF + text → FTS5] + P2 --> FC[Index File Content\ncanvas + PDF + text\n+ HTML + scripts → FTS5] FC --> P3[Pass 3: Embed Notes\nchunk → hash → embed → store] P3 --> P3F[Embed File Content\nchunk → hash → embed → store] FC --> BTP[Body-Tag Pass\nparse in worker → write changed tags] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3710638f7..31a94f34d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -313,26 +313,26 @@ these are its settings: Variables, in the order the deploy form shows them (everything after the six inputs sits under **Pre-Configured Environment Variables**, collapsed): -| Variable | Value | Description shown on the deploy form | -| --------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `TZ` | _(optional input)_ | Your timezone as an [IANA name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) (`America/Toronto`) — decides what "today" means for daily notes, task due dates, and memory timestamps. Leave empty for UTC | -| `VAULT_NAME` | _(required input)_ | Your vault's name, the same as it is in Obsidian | -| `VAULT_PASSWORD` | _(optional input)_ | Only if your vault uses end-to-end encryption; otherwise leave empty | -| `SYNC_FILE_TYPES` | _(optional input)_ | Attachment types to sync, comma-separated — the same toggles as Obsidian's Sync → Selective sync. **Valid values:** image, audio, video, pdf, unsupported. **Leave empty for the default:** image,audio,video,pdf. To read CSV, JSON, TXT, XML, LOG, and YAML files, enter image,audio,video,pdf,unsupported and turn on **Sync all other types** in Obsidian's Sync settings on the device that has them | -| `OBSIDIAN_AUTH_TOKEN` | _(optional input)_ | Leave empty to sign in through the `/setup` page after deploy, or paste a token from `npx vault-cortex@latest get-sync-token` | -| `SYNC_EXCLUDED_FOLDERS` | _(optional input)_ | Folders to leave out of sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Leave empty to exclude nothing | -| `MCP_AUTH_TOKEN` | `${{secret(64, "0123456789abcdef")}}` | Generated for you — the token your MCP client enters on the consent page | -| `PORT` | `8000` | The port the image listens on. Leave as is. | -| `STORAGE_ROOT` | `/persist` | Where the volume is mounted — vault, search index and its models, Sync device state, and logs live under it. Leave as is. | -| `DEVICE_NAME` | `vault-cortex` | The device name that labels this container's changes in Obsidian's sync log | -| `TRUST_PROXY_HOPS` | `2` | Railway proxies between a visitor and the container, so the server sees the visitor's real address | -| `RAILWAY_HEALTHCHECK_TIMEOUT_SEC` | `900` | How long Railway waits for the first health check — the first start downloads the vault and builds the index | -| `MEMORY_ENABLED` | `true` | The About Me/ memory layer and its tools. Set false to hide them and skip creating the folder | -| `EMBEDDING_ENABLED` | `true` | Semantic search. Set false to skip the models and use keyword search only — fits in much less memory | -| `READONLY_MODE` | `false` | Set true to hide every tool that changes the vault — clients can only read and search | -| `FILE_TOOLS_ENABLED` | `true` | vault_read_file and vault_list_files. Set false when Obsidian Sync has attachment syncing off | -| `SYNC_MODE` | `bidirectional` | Sync direction: bidirectional, pull-only (edits made on the server stay on the server and are never uploaded), or mirror-remote (Obsidian Sync overwrites edits made on the server, so the server always matches your vault). An invalid value stops the container at boot | -| `CONFLICT_STRATEGY` | `merge` | Obsidian Sync conflict resolution: merge integrates changes automatically; conflict writes a separate conflict file | +| Variable | Value | Description shown on the deploy form | +| --------------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TZ` | _(optional input)_ | Your timezone as an [IANA name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) (`America/Toronto`) — decides what "today" means for daily notes, task due dates, and memory timestamps. Leave empty for UTC | +| `VAULT_NAME` | _(required input)_ | Your vault's name, the same as it is in Obsidian | +| `VAULT_PASSWORD` | _(optional input)_ | Only if your vault uses end-to-end encryption; otherwise leave empty | +| `SYNC_FILE_TYPES` | _(optional input)_ | Attachment types to sync, comma-separated — the same toggles as Obsidian's Sync → Selective sync. **Valid values:** image, audio, video, pdf, unsupported. **Leave empty for the default:** image,audio,video,pdf. To read text files such as CSV, JSON, TXT, HTML and scripts, enter image,audio,video,pdf,unsupported and turn on **Sync all other types** in Obsidian's Sync settings on the device that has them | +| `OBSIDIAN_AUTH_TOKEN` | _(optional input)_ | Leave empty to sign in through the `/setup` page after deploy, or paste a token from `npx vault-cortex@latest get-sync-token` | +| `SYNC_EXCLUDED_FOLDERS` | _(optional input)_ | Folders to leave out of sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Leave empty to exclude nothing | +| `MCP_AUTH_TOKEN` | `${{secret(64, "0123456789abcdef")}}` | Generated for you — the token your MCP client enters on the consent page | +| `PORT` | `8000` | The port the image listens on. Leave as is. | +| `STORAGE_ROOT` | `/persist` | Where the volume is mounted — vault, search index and its models, Sync device state, and logs live under it. Leave as is. | +| `DEVICE_NAME` | `vault-cortex` | The device name that labels this container's changes in Obsidian's sync log | +| `TRUST_PROXY_HOPS` | `2` | Railway proxies between a visitor and the container, so the server sees the visitor's real address | +| `RAILWAY_HEALTHCHECK_TIMEOUT_SEC` | `900` | How long Railway waits for the first health check — the first start downloads the vault and builds the index | +| `MEMORY_ENABLED` | `true` | The About Me/ memory layer and its tools. Set false to hide them and skip creating the folder | +| `EMBEDDING_ENABLED` | `true` | Semantic search. Set false to skip the models and use keyword search only — fits in much less memory | +| `READONLY_MODE` | `false` | Set true to hide every tool that changes the vault — clients can only read and search | +| `FILE_TOOLS_ENABLED` | `true` | vault_read_file and vault_list_files. Set false when Obsidian Sync has attachment syncing off | +| `SYNC_MODE` | `bidirectional` | Sync direction: bidirectional, pull-only (edits made on the server stay on the server and are never uploaded), or mirror-remote (Obsidian Sync overwrites edits made on the server, so the server always matches your vault). An invalid value stops the container at boot | +| `CONFLICT_STRATEGY` | `merge` | Obsidian Sync conflict resolution: merge integrates changes automatically; conflict writes a separate conflict file | Update the template whenever the image tag, a boot-required variable, the port, or the health path changes, then re-publish; existing deployments keep diff --git a/DEPLOY.md b/DEPLOY.md index 021c6b2d9..88481163d 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -354,34 +354,34 @@ To find your stage: `cat .sst/stage` (after one-time setup). **Variables** (Settings → Secrets and variables → Actions → Variables tab): -| Variable | Purpose | -| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GHCR_USER` | GitHub username. Used in image tags and instance `.env`. | -| `DOCKERHUB_USERNAME` | Optional. Docker Hub username. When set, images are mirrored to Docker Hub alongside GHCR on release and README syncs automatically. Omit to skip entirely. | -| `EMBEDDING_ENABLED` | Optional. Set `false` to disable the embedding pipeline — skips model download, vector tables, embedding passes, and hybrid search. Search falls back to FTS5 keyword matching. Default: `true`. | -| `RERANK_MODE` | Optional. Cross-encoder reranking mode: `blended` (default) applies position-aware score blending after RRF fusion, `none` skips reranking for lower latency. Only takes effect when `EMBEDDING_ENABLED` is true. | -| `MEMORY_ENABLED` | Optional. Set `false` to disable the memory layer entirely — hides memory tools, skips bootstrap, omits memory from server metadata. Default: `true`. | -| `FILE_TOOLS_ENABLED` | Optional. Set `false` to hide file tools (`vault_read_file`, `vault_list_files`) for deployments without synced attachments. Default: `true`. | -| `READONLY_MODE` | Optional. Set `true` to hide every tool that changes the vault and skip memory folder auto-creation — read and search only. Default: `false`. | -| `DISABLED_TOOLS` | Optional. Hide individual tools by name, comma-separated. Names match the Tool column in the [README tools table](./README.md#tools). Subtractive only; an unknown tool name stops the server at startup. Default: none hidden. | -| `MEMORY_DIR` | Optional. Memory folder name in the vault (default: `About Me`). See the [Configuration](./README.md#configuration) section. | -| `PROTECTED_PATHS` | Optional. Comma-separated folders protected from deletion and moves (default: `MEMORY_DIR` plus the daily notes folder, read from `DAILY_NOTES_FOLDER` or `.obsidian/daily-notes.json`, default `Daily Notes`; none when that setting is the vault root or empty). Overrides the default entirely when set. | -| `ORPHAN_EXCLUDE_FOLDERS` | Optional. Comma-separated folders excluded from orphan detection (default: the daily notes folder, `Templates`, and `MEMORY_DIR`). The `DAILY_NOTES_FOLDER` variable wins; otherwise synced `.obsidian/daily-notes.json` is reread per query (fallback `Daily Notes`). Overrides the default entirely when set. Use `,` to exclude nothing; an empty value uses the defaults. Apply variable changes with Force Deploy; synced Obsidian changes need no redeploy. See [Daily notes](./README.md#daily-notes) for root and empty settings. | -| `SERVICE_DOCUMENTATION_URL` | Optional. URL in OAuth discovery metadata (default: `https://github.com/aliasunder/vault-cortex`). Set to your fork's URL. | -| `SYNC_CONFIGS` | Optional. Obsidian settings categories synced to the server, comma-separated (default: `core-plugin-data,community-plugin-data,app` — daily-notes settings, the Tasks plugin's format, and the new-note location). Set `none` to disable. See [Daily notes](./README.md#daily-notes). | -| `SYNC_EXCLUDED_FOLDERS` | Optional. Folders to leave out of Obsidian Sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Unset syncs everything. | -| `SYNC_FILE_TYPES` | Optional. Attachment types Obsidian Sync delivers, comma-separated. **Valid values:** `image`, `audio`, `video`, `pdf`, `unsupported`. **Unset (default):** `image,audio,video,pdf`. The text files Vault Cortex reads (CSV, JSON, TXT, XML, LOG, YAML) fall under `unsupported`, Obsidian Sync's name for its **Sync all other types** category; SVG syncs as `image`, and notes, canvases, and bases always sync. To read those text files, set it to `image,audio,video,pdf,unsupported` (the value replaces the default list, so keep the four defaults) and turn on **Sync all other types** in Obsidian's Sync settings on the device that has them. An invalid value is skipped with a warning in the logs, and the default types sync instead. | -| `DAILY_NOTES_FOLDER` | Optional. Sets the daily notes folder: any vault-relative path, or `/` for the vault root (default: read from the vault's `.obsidian/daily-notes.json`; `Daily Notes` when that file is missing or unreadable). See [Daily notes](./README.md#daily-notes). | -| `DAILY_NOTES_FORMAT` | Optional. Sets the daily note filename format, in the same tokens as Obsidian's daily note date format setting (default: read from the vault's config, falling back to `YYYY-MM-DD`). | -| `TZ` | Optional. Container timezone (default: `UTC`). Affects `vault_update_memory` date stamps and `vault_get_daily_note` date resolution. Set to your IANA timezone (e.g. `America/New_York`). | -| `LOG_LEVEL` | Optional. Logging verbosity: `debug`, `info`, `warn`, `error`. Default: `info`. | -| `LOG_DIR` | Optional. Directory for persistent log files inside the container. Default: `/data/logs`; `none` disables log files. | -| `LOG_RETENTION_DAYS` | Optional. Days to keep log files before automatic cleanup on startup; only applies when `LOG_DIR` is a path. Default: `90`. | -| `WINDOWS_MODE` | Optional. Set `true` when the vault is on a Windows drive (Docker Desktop). Default: `false`. | -| `TRUST_PROXY_HOPS` | Optional. Trusted reverse-proxy hops for deriving `req.ip` from `X-Forwarded-For` (the fallback client IP). The compose file defaults the instance to `1` (API Gateway); set `2` for `ORIGIN_URL` deployments (a tunnel between the gateway and the instance). | -| `TRUST_FORWARDED_HOPS` | Optional. How many entries from the end of the [RFC 7239](https://www.rfc-editor.org/rfc/rfc7239) `Forwarded` header's `for=` list to count to reach the client IP (OAuth rate limiting, request logs); `0` ignores the header. The compose file defaults the instance to `1` (API Gateway reports visitors there and sends no `X-Forwarded-For`). For `ORIGIN_URL` and CDN setups, see the **Client-IP trust with ORIGIN_URL** callout under [Port 8000 Hardening](#port-8000-hardening-optional). | -| `ORIGIN_ACCESS_SERVICE_TOKEN_ENABLED` | Optional, SST deploys only. Set `true` once the tunnel hostname behind `ORIGIN_URL` is locked with a Cloudflare Access service token — API Gateway then presents the `OriginAccessClientId` / `OriginAccessClientSecret` SST secrets on every request (see [Port 8000 Hardening](#port-8000-hardening-optional), steps 5–8). It has no effect unless `ORIGIN_URL` is set. | -| `DISABLE_EXECUTE_API_ENDPOINT` | Optional. Requires the `CUSTOM_DOMAIN` secret. Set `true` once every client uses the custom domain: the gateway stops answering on its default `execute-api` hostname at the next deploy. If a CDN sits in front of the custom domain, set this to `true` before setting `TRUST_FORWARDED_HOPS=2`; the **Client-IP trust with ORIGIN_URL** callout under [Port 8000 Hardening](#port-8000-hardening-optional) explains why. | +| Variable | Purpose | +| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GHCR_USER` | GitHub username. Used in image tags and instance `.env`. | +| `DOCKERHUB_USERNAME` | Optional. Docker Hub username. When set, images are mirrored to Docker Hub alongside GHCR on release and README syncs automatically. Omit to skip entirely. | +| `EMBEDDING_ENABLED` | Optional. Set `false` to disable the embedding pipeline — skips model download, vector tables, embedding passes, and hybrid search. Search falls back to FTS5 keyword matching. Default: `true`. | +| `RERANK_MODE` | Optional. Cross-encoder reranking mode: `blended` (default) applies position-aware score blending after RRF fusion, `none` skips reranking for lower latency. Only takes effect when `EMBEDDING_ENABLED` is true. | +| `MEMORY_ENABLED` | Optional. Set `false` to disable the memory layer entirely — hides memory tools, skips bootstrap, omits memory from server metadata. Default: `true`. | +| `FILE_TOOLS_ENABLED` | Optional. Set `false` to hide file tools (`vault_read_file`, `vault_list_files`) for deployments without synced attachments. Default: `true`. | +| `READONLY_MODE` | Optional. Set `true` to hide every tool that changes the vault and skip memory folder auto-creation — read and search only. Default: `false`. | +| `DISABLED_TOOLS` | Optional. Hide individual tools by name, comma-separated. Names match the Tool column in the [README tools table](./README.md#tools). Subtractive only; an unknown tool name stops the server at startup. Default: none hidden. | +| `MEMORY_DIR` | Optional. Memory folder name in the vault (default: `About Me`). See the [Configuration](./README.md#configuration) section. | +| `PROTECTED_PATHS` | Optional. Comma-separated folders protected from deletion and moves (default: `MEMORY_DIR` plus the daily notes folder, read from `DAILY_NOTES_FOLDER` or `.obsidian/daily-notes.json`, default `Daily Notes`; none when that setting is the vault root or empty). Overrides the default entirely when set. | +| `ORPHAN_EXCLUDE_FOLDERS` | Optional. Comma-separated folders excluded from orphan detection (default: the daily notes folder, `Templates`, and `MEMORY_DIR`). The `DAILY_NOTES_FOLDER` variable wins; otherwise synced `.obsidian/daily-notes.json` is reread per query (fallback `Daily Notes`). Overrides the default entirely when set. Use `,` to exclude nothing; an empty value uses the defaults. Apply variable changes with Force Deploy; synced Obsidian changes need no redeploy. See [Daily notes](./README.md#daily-notes) for root and empty settings. | +| `SERVICE_DOCUMENTATION_URL` | Optional. URL in OAuth discovery metadata (default: `https://github.com/aliasunder/vault-cortex`). Set to your fork's URL. | +| `SYNC_CONFIGS` | Optional. Obsidian settings categories synced to the server, comma-separated (default: `core-plugin-data,community-plugin-data,app` — daily-notes settings, the Tasks plugin's format, and the new-note location). Set `none` to disable. See [Daily notes](./README.md#daily-notes). | +| `SYNC_EXCLUDED_FOLDERS` | Optional. Folders to leave out of Obsidian Sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Unset syncs everything. | +| `SYNC_FILE_TYPES` | Optional. Attachment types Obsidian Sync delivers, comma-separated. **Valid values:** `image`, `audio`, `video`, `pdf`, `unsupported`. **Unset (default):** `image,audio,video,pdf`. The text files Vault Cortex reads (CSV, JSON, TXT, HTML, scripts and other UTF-8 files) fall under `unsupported`, Obsidian Sync's name for its **Sync all other types** category; SVG syncs as `image`, and notes, canvases, and bases always sync. To read those text files, set it to `image,audio,video,pdf,unsupported` (the value replaces the default list, so keep the four defaults) and turn on **Sync all other types** in Obsidian's Sync settings on the device that has them. An invalid value is skipped with a warning in the logs, and the default types sync instead. | +| `DAILY_NOTES_FOLDER` | Optional. Sets the daily notes folder: any vault-relative path, or `/` for the vault root (default: read from the vault's `.obsidian/daily-notes.json`; `Daily Notes` when that file is missing or unreadable). See [Daily notes](./README.md#daily-notes). | +| `DAILY_NOTES_FORMAT` | Optional. Sets the daily note filename format, in the same tokens as Obsidian's daily note date format setting (default: read from the vault's config, falling back to `YYYY-MM-DD`). | +| `TZ` | Optional. Container timezone (default: `UTC`). Affects `vault_update_memory` date stamps and `vault_get_daily_note` date resolution. Set to your IANA timezone (e.g. `America/New_York`). | +| `LOG_LEVEL` | Optional. Logging verbosity: `debug`, `info`, `warn`, `error`. Default: `info`. | +| `LOG_DIR` | Optional. Directory for persistent log files inside the container. Default: `/data/logs`; `none` disables log files. | +| `LOG_RETENTION_DAYS` | Optional. Days to keep log files before automatic cleanup on startup; only applies when `LOG_DIR` is a path. Default: `90`. | +| `WINDOWS_MODE` | Optional. Set `true` when the vault is on a Windows drive (Docker Desktop). Default: `false`. | +| `TRUST_PROXY_HOPS` | Optional. Trusted reverse-proxy hops for deriving `req.ip` from `X-Forwarded-For` (the fallback client IP). The compose file defaults the instance to `1` (API Gateway); set `2` for `ORIGIN_URL` deployments (a tunnel between the gateway and the instance). | +| `TRUST_FORWARDED_HOPS` | Optional. How many entries from the end of the [RFC 7239](https://www.rfc-editor.org/rfc/rfc7239) `Forwarded` header's `for=` list to count to reach the client IP (OAuth rate limiting, request logs); `0` ignores the header. The compose file defaults the instance to `1` (API Gateway reports visitors there and sends no `X-Forwarded-For`). For `ORIGIN_URL` and CDN setups, see the **Client-IP trust with ORIGIN_URL** callout under [Port 8000 Hardening](#port-8000-hardening-optional). | +| `ORIGIN_ACCESS_SERVICE_TOKEN_ENABLED` | Optional, SST deploys only. Set `true` once the tunnel hostname behind `ORIGIN_URL` is locked with a Cloudflare Access service token — API Gateway then presents the `OriginAccessClientId` / `OriginAccessClientSecret` SST secrets on every request (see [Port 8000 Hardening](#port-8000-hardening-optional), steps 5–8). It has no effect unless `ORIGIN_URL` is set. | +| `DISABLE_EXECUTE_API_ENDPOINT` | Optional. Requires the `CUSTOM_DOMAIN` secret. Set `true` once every client uses the custom domain: the gateway stops answering on its default `execute-api` hostname at the next deploy. If a CDN sits in front of the custom domain, set this to `true` before setting `TRUST_FORWARDED_HOPS=2`; the **Client-IP trust with ORIGIN_URL** callout under [Port 8000 Hardening](#port-8000-hardening-optional) explains why. | **Secrets** (Settings → Secrets and variables → Actions → Secrets tab): diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 8a7517ee5..e2047bd36 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -48,7 +48,7 @@ - **[Structured memory](https://github.com/aliasunder/vault-cortex#memory)** — dated, append-only entries accumulate into a personal knowledge layer, auto-initialized for AI personalization. Topic recall answers "what do I think about X?" with the current take and the dated history behind it — evolution included. - **[Tasks](https://github.com/aliasunder/vault-cortex#tasks)** — Kanban-aware task queries and updates: triage by status, dates, or priority, then complete, reprioritize, or move tasks between lanes in one call. Completing a recurring task spawns its next occurrence. Parses both [Tasks plugin](https://publish.obsidian.md/tasks/) emoji and [Dataview](https://blacksmithgu.github.io/obsidian-dataview/) inline-field formats. - **[Link graph](https://github.com/aliasunder/vault-cortex#tools)** — backlinks, outgoing links, and orphan detection across the vault -- **[Files](https://github.com/aliasunder/vault-cortex#files)** — read the vault's non-markdown files too: images arrive as actual images (shrunk to fit when needed), PDFs as structured text or rendered pages, canvases as readable outlines, data files as text +- **[Files](https://github.com/aliasunder/vault-cortex#files)** — read the vault's non-markdown files too: images arrive as actual images (shrunk to fit when needed), PDFs as structured text or rendered pages, canvases as readable outlines, data files, HTML pages, and scripts as text - **[Obsidian-native](https://github.com/aliasunder/vault-cortex#properties)** — understands frontmatter, wikilinks, tags, headings, and daily notes - **[Guided workflows](https://github.com/aliasunder/vault-cortex#prompts)** — built-in prompts for vault health, memory review, and daily reconciliation — assembled from live vault data each time @@ -61,22 +61,6 @@ See the [full Quick Start guide](https://github.com/aliasunder/vault-cortex#quick-start) for local setup (2 minutes with Docker), one-click hosting on Render or Railway, remote deployment with Obsidian Sync, and MCP client configuration. -## Files - -Your notes embed screenshots, reference architecture diagrams, and link out to canvases and data files — but to an agent reading markdown, `![[diagram.png]]` is just text. Vault Cortex treats files as part of the vault rather than clutter around it — linked, sized, and readable, each in the form an agent can use: - -- **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side when they exceed what MCP clients accept, so even a phone session can look at a 5MB architecture diagram -- **Canvases** — a [Canvas](https://help.obsidian.md/canvas) board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them. Canvas content is full-text searchable, and file references on the board appear in the link graph — backlinks and outgoing links work just like note-to-note links. The exact JSON source is one flag away when full fidelity matters -- **PDFs** — text is extracted with heading hierarchy, code blocks, and hyperlinks preserved; PDF content is full-text searchable alongside your notes. Set `raw: true` to render pages as images instead, showing layout, diagrams, and tables that text extraction can't preserve — scanned and image-only PDFs work in this mode -- **Text and data files** — TXT, SVG, JSON, XML, CSV, YAML, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written; the first 100 KB of content is full-text searchable. Big data files and logs can be read a line range at a time, with each page reporting where you are and how much file remains -- **Browse** — list any visible folder's files with per-extension counts and file sizes; files a note links to report their size in the link graph too - -Set `FILE_TOOLS_ENABLED=false` to hide the file tools — useful when your remote vault syncs without attachments. - -See [ARCHITECTURE.md → Files](https://github.com/aliasunder/vault-cortex/blob/main/ARCHITECTURE.md#files) for the image pipeline and dispatch model. - ---- - ## Tools | Category | Tool | Description | diff --git a/README.md b/README.md index 3458cf5b4..e185bfe7b 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ - **[Structured memory](#memory)** — dated, append-only entries accumulate into a personal knowledge layer, auto-initialized for AI personalization. Topic recall answers "what do I think about X?" with the current take and the dated history behind it — evolution included. - **[Tasks](#tasks)** — Kanban-aware task queries and updates: triage by status, dates, or priority, then complete, reprioritize, or move tasks between lanes in one call. Completing a recurring task spawns its next occurrence. Parses both [Tasks plugin](https://publish.obsidian.md/tasks/) emoji and [Dataview](https://blacksmithgu.github.io/obsidian-dataview/) inline-field formats. - **[Link graph](#tools)** — backlinks, outgoing links, and orphan detection across the vault -- **[Files](#files)** — read the vault's non-markdown files too: images arrive as actual images (shrunk to fit when needed), PDFs as structured text or rendered pages, canvases as readable outlines, data files as text +- **[Files](#files)** — read the vault's non-markdown files too: images arrive as actual images (shrunk to fit when needed), PDFs as structured text or rendered pages, canvases as readable outlines, data files, HTML pages, and scripts as text - **[Obsidian-native](#properties)** — understands frontmatter, wikilinks, tags, headings, and daily notes - **[Guided workflows](#prompts)** — built-in prompts for vault health, memory review, and daily reconciliation — assembled from live vault data each time @@ -282,7 +282,9 @@ Your notes embed screenshots, reference architecture diagrams, and link out to c - **Images** — the image itself, not the filename. Screenshots and diagrams are downscaled and recompressed server-side when they exceed what MCP clients accept, so even a phone session can look at a 5MB architecture diagram - **Canvases** — a [Canvas](https://help.obsidian.md/canvas) board arrives as a readable outline: its groups, each card's content in reading order, and the connections between them. Canvas content is full-text searchable, and file references on the board appear in the link graph — backlinks and outgoing links work just like note-to-note links. The exact JSON source is one flag away when full fidelity matters - **PDFs** — text is extracted with heading hierarchy, code blocks, and hyperlinks preserved; PDF content is full-text searchable alongside your notes. Set `raw: true` to render pages as images instead, showing layout, diagrams, and tables that text extraction can't preserve — scanned and image-only PDFs work in this mode -- **Text and data files** — TXT, SVG, JSON, XML, CSV, YAML, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written; the first 100 KB of content is full-text searchable. Big data files and logs can be read a line range at a time, with each page reporting where you are and how much file remains +- **Text and data files** — TXT, SVG, JSON, XML, CSV, YAML, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written, apart from a leading byte-order mark, and their first 100 KB is full-text searchable. Big data files and logs can be read a line range at a time, with each page reporting where you are and how much file remains +- **HTML pages and scripts** — a saved page or generated report is searchable by the first 100 KB of the text a browser shows, never its tags or script code. Python, shell, and PowerShell scripts are searchable by keyword. Both must be UTF-8 text, and both read back as their source. A page or script over 16 MB is searchable by its name only +- **Other text files** — a file of any other type, such as a `.js` file, a `.toml` config or a file with no extension, reads back the way a text file does when its content is UTF-8. Search doesn't index these, so browse a folder to find them - **Browse** — list any visible folder's files with per-extension counts and file sizes; files a note links to report their size in the link graph too Set `FILE_TOOLS_ENABLED=false` to hide the file tools — useful when your remote vault syncs without attachments. diff --git a/SECURITY.md b/SECURITY.md index 957f7124f..4e7d4f6b1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -90,8 +90,14 @@ mechanism-level detail. database stores refresh tokens keyed by an HMAC under `MCP_AUTH_TOKEN`, so a copy of it holds no usable credential. Hidden-path blocking does not cover a database relocated into a - _visible_ vault folder — there it is readable like any other vault - file. + _visible_ vault folder — there `vault_list_files` lists it like any + other vault file, and `vault_read_file` refuses it only because a + SQLite file holds binary data. +- `vault_read_file` returns any visible non-markdown file whose bytes are + UTF-8 text, whatever its extension, so a credential kept in a visible vault + folder (a `deploy.env` or `secrets.txt`) is readable by any signed-in client. + A file named as an image or a PDF goes to the image or PDF reader instead. + Hidden files such as `.env` stay refused by the hidden-path check above. - The hidden-path check is lexical: a visible symlink whose target is a hidden path is followed. Creating such a symlink requires direct filesystem access — no tool can create one, and anyone with that diff --git a/cli/README.md b/cli/README.md index 5b8543820..3a81e1e2f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -10,7 +10,7 @@ npx vault-cortex@latest init Vault Cortex is a standalone, remote-capable MCP server that gives any AI agent hybrid search, task management, structured memory, and read/write access to your Obsidian vault — including its images, PDFs, canvases, -and data files — see the +web pages, scripts, and data files — see the [full feature overview](https://github.com/aliasunder/vault-cortex#what-you-get). The server runs as a Docker container; this CLI scaffolds the config and manages the container so you don't have to. diff --git a/cli/src/env.ts b/cli/src/env.ts index 0e9496d1e..2b7be7fbb 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -369,9 +369,10 @@ SYNC_EXCLUDED_FOLDERS= # Sync → "Selective sync". # Valid values: image | audio | video | pdf | unsupported # Empty (default): image,audio,video,pdf -# The text files Vault Cortex reads (CSV, JSON, TXT, XML, LOG, YAML) fall -# under "unsupported", Obsidian Sync's name for its "Sync all other types" -# category; SVG syncs as "image", and notes, canvases, and bases always sync. +# The text files Vault Cortex reads (CSV, JSON, TXT, HTML, scripts and other +# UTF-8 files) fall under "unsupported", Obsidian Sync's name for its "Sync +# all other types" category; SVG syncs as "image", and notes, canvases, and +# bases always sync. # To read those text files, set # SYNC_FILE_TYPES=image,audio,video,pdf,unsupported (the value replaces the # default list, so keep the four defaults) and turn on "Sync all other types" diff --git a/deploy/railway/README.md b/deploy/railway/README.md index c62529b88..63cfe4ca7 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -43,14 +43,14 @@ The button opens the template's page. Click **Deploy Now**, then ![Railway deploy form showing TZ, VAULT_NAME, VAULT_PASSWORD, SYNC_FILE_TYPES, OBSIDIAN_AUTH_TOKEN, and SYNC_EXCLUDED_FOLDERS fields](img/railway-configure.jpg) -| Field | Value | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `TZ` | Your timezone as an [IANA name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) (`America/Toronto`) — decides what "today" means for daily notes, task due dates, and memory timestamps. Leave empty for UTC | -| `VAULT_NAME` | Your vault's name, the same as it is in Obsidian | -| `VAULT_PASSWORD` | Only if your vault uses end-to-end encryption; otherwise leave empty | -| `SYNC_FILE_TYPES` | Attachment types to sync, comma-separated. **Valid values:** `image`, `audio`, `video`, `pdf`, `unsupported`. **Leave empty for the default:** `image,audio,video,pdf`. To read CSV, JSON, TXT, XML, LOG, and YAML files, enter `image,audio,video,pdf,unsupported` and turn on **Sync all other types** for the device that has them | -| `OBSIDIAN_AUTH_TOKEN` | Leave empty — you sign in through the setup page after deploy (see [Sign in to Obsidian Sync](#sign-in-to-obsidian-sync)). Or paste a token from `npx vault-cortex@latest get-sync-token` to skip the setup page | -| `SYNC_EXCLUDED_FOLDERS` | Folders to leave out of sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Leave empty to sync everything | +| Field | Value | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `TZ` | Your timezone as an [IANA name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List) (`America/Toronto`) — decides what "today" means for daily notes, task due dates, and memory timestamps. Leave empty for UTC | +| `VAULT_NAME` | Your vault's name, the same as it is in Obsidian | +| `VAULT_PASSWORD` | Only if your vault uses end-to-end encryption; otherwise leave empty | +| `SYNC_FILE_TYPES` | Attachment types to sync, comma-separated. **Valid values:** `image`, `audio`, `video`, `pdf`, `unsupported`. **Leave empty for the default:** `image,audio,video,pdf`. To read text files such as CSV, JSON, TXT, HTML and scripts, enter `image,audio,video,pdf,unsupported` and turn on **Sync all other types** for the device that has them | +| `OBSIDIAN_AUTH_TOKEN` | Leave empty — you sign in through the setup page after deploy (see [Sign in to Obsidian Sync](#sign-in-to-obsidian-sync)). Or paste a token from `npx vault-cortex@latest get-sync-token` to skip the setup page | +| `SYNC_EXCLUDED_FOLDERS` | Folders to leave out of sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Leave empty to sync everything | Fill in at least the vault name **before** the first deploy. Click **Deploy**. Railway creates the service and volume, pulls the image, and @@ -270,27 +270,27 @@ release lists what changed since the last one, and an entry marked The template sets these; change them under the service's **Variables** tab (Railway stages the change and redeploys when you apply it): -| Variable | Value | What it does | -| --------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `STORAGE_ROOT` | `/persist` | Where the volume is mounted — the vault, search index and its models, Sync device state, and logs live under it. Must not contain `*`, `?`, or `[`. Leave as is. | -| `PORT` | `8000` | The port the image listens on. Leave as is. | -| `DEVICE_NAME` | `vault-cortex` | The device name that labels this container's changes in Obsidian's sync log. | -| `TRUST_PROXY_HOPS` | `2` | Two Railway proxies sit between a visitor and the container; this lets the server see the visitor's real address in its logs and rate limits. | -| `RAILWAY_HEALTHCHECK_TIMEOUT_SEC` | `900` | How long Railway waits for the first health check — the first start downloads the vault and builds the index. | -| `MCP_AUTH_TOKEN` | generated | Your MCP client's token. Change it here to rotate it — every connected client re-authorizes. | -| `OBSIDIAN_AUTH_TOKEN` | from setup page | Obsidian Sync login. Set by the setup page on first deploy (written to the volume). To sign in with a different account, set a new token here — it always overrides the file on the volume. | -| `VAULT_NAME` | yours | The vault this container syncs. | -| `VAULT_PASSWORD` | yours / empty | End-to-end encryption password, if your vault has one. | -| `TZ` | yours / empty | Timezone for daily notes, task due dates, and memory timestamps. Empty means UTC. | -| `MEMORY_ENABLED` | `true` | The About Me/ memory layer and its tools. Set `false` to hide them and skip creating the folder. | -| `EMBEDDING_ENABLED` | `true` | Semantic search. Set `false` to skip the models and use keyword search only — the container fits in much less memory. | -| `READONLY_MODE` | `false` | Set `true` to hide every tool that changes the vault — clients can only read and search. | -| `FILE_TOOLS_ENABLED` | `true` | `vault_read_file` and `vault_list_files`. Set `false` when Obsidian Sync has attachment syncing off. | -| `SYNC_MODE` | `bidirectional` | Sync direction: `bidirectional`, `pull-only` (edits made on the server stay on the server and are never uploaded), or `mirror-remote` (Obsidian Sync overwrites edits made on the server, so the server always matches your vault). An invalid value stops the container at boot. | -| `CONFLICT_STRATEGY` | `merge` | Obsidian Sync conflict resolution: `merge` integrates changes automatically; `conflict` writes a separate conflict file. | -| `SYNC_EXCLUDED_FOLDERS` | _(empty)_ | Folders to leave out of sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Empty excludes nothing. | -| `SYNC_FILE_TYPES` | _(empty)_ | Attachment types to sync, comma-separated — the same toggles as Obsidian's Sync → Selective sync. **Valid values:** `image`, `audio`, `video`, `pdf`, `unsupported`. **Empty (default):** `image,audio,video,pdf`. The text files Vault Cortex reads (CSV, JSON, TXT, XML, LOG, YAML) fall under `unsupported`, Obsidian Sync's name for its **Sync all other types** category; SVG syncs as `image`, and notes, canvases, and bases always sync. To read those text files, set it to `image,audio,video,pdf,unsupported` (the value replaces the default list, so keep the four defaults) and turn on **Sync all other types** in Obsidian's Sync settings on the device that has them. An invalid value is skipped with a warning in the logs, and the default types sync instead. | -| `LOG_DIR` | derived | Log files, kept 90 days on the volume — the platform's own log viewer keeps only the last 7 days on Hobby plans. Set `none` to keep logs in the platform viewer only. | +| Variable | Value | What it does | +| --------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STORAGE_ROOT` | `/persist` | Where the volume is mounted — the vault, search index and its models, Sync device state, and logs live under it. Must not contain `*`, `?`, or `[`. Leave as is. | +| `PORT` | `8000` | The port the image listens on. Leave as is. | +| `DEVICE_NAME` | `vault-cortex` | The device name that labels this container's changes in Obsidian's sync log. | +| `TRUST_PROXY_HOPS` | `2` | Two Railway proxies sit between a visitor and the container; this lets the server see the visitor's real address in its logs and rate limits. | +| `RAILWAY_HEALTHCHECK_TIMEOUT_SEC` | `900` | How long Railway waits for the first health check — the first start downloads the vault and builds the index. | +| `MCP_AUTH_TOKEN` | generated | Your MCP client's token. Change it here to rotate it — every connected client re-authorizes. | +| `OBSIDIAN_AUTH_TOKEN` | from setup page | Obsidian Sync login. Set by the setup page on first deploy (written to the volume). To sign in with a different account, set a new token here — it always overrides the file on the volume. | +| `VAULT_NAME` | yours | The vault this container syncs. | +| `VAULT_PASSWORD` | yours / empty | End-to-end encryption password, if your vault has one. | +| `TZ` | yours / empty | Timezone for daily notes, task due dates, and memory timestamps. Empty means UTC. | +| `MEMORY_ENABLED` | `true` | The About Me/ memory layer and its tools. Set `false` to hide them and skip creating the folder. | +| `EMBEDDING_ENABLED` | `true` | Semantic search. Set `false` to skip the models and use keyword search only — the container fits in much less memory. | +| `READONLY_MODE` | `false` | Set `true` to hide every tool that changes the vault — clients can only read and search. | +| `FILE_TOOLS_ENABLED` | `true` | `vault_read_file` and `vault_list_files`. Set `false` when Obsidian Sync has attachment syncing off. | +| `SYNC_MODE` | `bidirectional` | Sync direction: `bidirectional`, `pull-only` (edits made on the server stay on the server and are never uploaded), or `mirror-remote` (Obsidian Sync overwrites edits made on the server, so the server always matches your vault). An invalid value stops the container at boot. | +| `CONFLICT_STRATEGY` | `merge` | Obsidian Sync conflict resolution: `merge` integrates changes automatically; `conflict` writes a separate conflict file. | +| `SYNC_EXCLUDED_FOLDERS` | _(empty)_ | Folders to leave out of sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Empty excludes nothing. | +| `SYNC_FILE_TYPES` | _(empty)_ | Attachment types to sync, comma-separated — the same toggles as Obsidian's Sync → Selective sync. **Valid values:** `image`, `audio`, `video`, `pdf`, `unsupported`. **Empty (default):** `image,audio,video,pdf`. The text files Vault Cortex reads (CSV, JSON, TXT, HTML, scripts and other UTF-8 files) fall under `unsupported`, Obsidian Sync's name for its **Sync all other types** category; SVG syncs as `image`, and notes, canvases, and bases always sync. To read those text files, set it to `image,audio,video,pdf,unsupported` (the value replaces the default list, so keep the four defaults) and turn on **Sync all other types** in Obsidian's Sync settings on the device that has them. An invalid value is skipped with a warning in the logs, and the default types sync instead. | +| `LOG_DIR` | derived | Log files, kept 90 days on the volume — the platform's own log viewer keeps only the last 7 days on Hobby plans. Set `none` to keep logs in the platform viewer only. | `SYNC_MODE`, `CONFLICT_STRATEGY`, `SYNC_EXCLUDED_FOLDERS`, and `SYNC_FILE_TYPES` are the settings most worth changing on a hosted instance; diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example index 24a18191a..1659f545d 100644 --- a/deploy/remote/.env.example +++ b/deploy/remote/.env.example @@ -200,9 +200,10 @@ SYNC_EXCLUDED_FOLDERS= # Sync → "Selective sync". # Valid values: image | audio | video | pdf | unsupported # Empty (default): image,audio,video,pdf -# The text files Vault Cortex reads (CSV, JSON, TXT, XML, LOG, YAML) fall -# under "unsupported", Obsidian Sync's name for its "Sync all other types" -# category; SVG syncs as "image", and notes, canvases, and bases always sync. +# The text files Vault Cortex reads (CSV, JSON, TXT, HTML, scripts and other +# UTF-8 files) fall under "unsupported", Obsidian Sync's name for its "Sync +# all other types" category; SVG syncs as "image", and notes, canvases, and +# bases always sync. # To read those text files, set # SYNC_FILE_TYPES=image,audio,video,pdf,unsupported (the value replaces the # default list, so keep the four defaults) and turn on "Sync all other types" diff --git a/deploy/remote/README.md b/deploy/remote/README.md index dd03663e3..4fe09b88c 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -729,28 +729,28 @@ Only `MCP_AUTH_TOKEN`, `PUBLIC_URL`, and `VAULT_NAME` are required. `OBSIDIAN_AUTH_TOKEN` can be filled via the `/setup` page when left blank. These optional settings are worth knowing about: -| Setting | Default | What it does | -| ----------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `TZ` | `UTC` | Your IANA timezone (e.g. `America/New_York`) — affects daily note dates and timestamps | -| `VAULT_PASSWORD` | — | Set this if your vault has end-to-end encryption enabled | -| `EMBEDDING_ENABLED` | `true` | Set `false` to skip AI models (~59MB) and use keyword search only — saves memory on smaller instances | -| `RERANK_MODE` | `blended` | Set `none` to skip reranking for lower latency | -| `MEMORY_ENABLED` | `true` | Set `false` to disable the structured memory layer | -| `FILE_TOOLS_ENABLED` | `true` | Set `false` to hide file tools when 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 — read and search only | -| `DISABLED_TOOLS` | none hidden | Hide individual tools by name, comma-separated; names match the [README tools table](https://github.com/aliasunder/vault-cortex#tools) | -| `SYNC_CONFIGS` | daily notes, plugins, and app | Obsidian settings categories synced to the server (see [Daily notes](#daily-notes)); `none` disables | -| `INSTANCE_NAME` | `vault-cortex` | Names this install's container, Compose project and volumes. Set it before the first start to run another vault on the same server (see [Run more than one vault](#run-more-than-one-vault)) | -| `DEVICE_NAME` | `vault-cortex` | The device name that labels this container's changes in Obsidian's sync log | -| `SYNC_MODE` | `bidirectional` | Sync direction: `bidirectional`, `pull-only` (edits made on the server stay on the server and are never uploaded), or `mirror-remote` (Obsidian Sync overwrites edits made on the server, so the server always matches your vault). An invalid value stops the container at boot. | -| `CONFLICT_STRATEGY` | `merge` | Obsidian Sync conflict resolution: `merge` integrates changes automatically; `conflict` writes a separate conflict file. | -| `SYNC_EXCLUDED_FOLDERS` | _(empty)_ | Folders to leave out of sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Empty excludes nothing. | -| `SYNC_FILE_TYPES` | _(empty)_ | Attachment types to sync, comma-separated — the same toggles as Obsidian's Sync → Selective sync. **Valid values:** `image`, `audio`, `video`, `pdf`, `unsupported`. **Empty (default):** `image,audio,video,pdf`. The text files Vault Cortex reads (CSV, JSON, TXT, XML, LOG, YAML) fall under `unsupported`, Obsidian Sync's name for its **Sync all other types** category; SVG syncs as `image`, and notes, canvases, and bases always sync. To read those text files, set it to `image,audio,video,pdf,unsupported` (the value replaces the default list, so keep the four defaults) and turn on **Sync all other types** in Obsidian's Sync settings on the device that has them. An invalid value is skipped with a warning in the logs, and the default types sync instead. | -| `DAILY_NOTES_FOLDER` | from vault config | Sets the daily notes folder (see [Daily notes](#daily-notes)) | -| `DAILY_NOTES_FORMAT` | from vault config | Sets the daily note filename format (see [Daily notes](#daily-notes)) | -| `TRUST_PROXY_HOPS` | `0` | Set `1` when a tunnel or reverse proxy fronts the server — OAuth rate limiting then buckets by the real client IP, not the proxy's | -| `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 | -| `STORAGE_ROOT` | — | One directory for everything that must persist, for hosting platforms that allow a single volume (see **Single persistent volume** under [Setup](#setup)). Must not contain `*`, `?`, or `[`. | +| Setting | Default | What it does | +| ----------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TZ` | `UTC` | Your IANA timezone (e.g. `America/New_York`) — affects daily note dates and timestamps | +| `VAULT_PASSWORD` | — | Set this if your vault has end-to-end encryption enabled | +| `EMBEDDING_ENABLED` | `true` | Set `false` to skip AI models (~59MB) and use keyword search only — saves memory on smaller instances | +| `RERANK_MODE` | `blended` | Set `none` to skip reranking for lower latency | +| `MEMORY_ENABLED` | `true` | Set `false` to disable the structured memory layer | +| `FILE_TOOLS_ENABLED` | `true` | Set `false` to hide file tools when 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 — read and search only | +| `DISABLED_TOOLS` | none hidden | Hide individual tools by name, comma-separated; names match the [README tools table](https://github.com/aliasunder/vault-cortex#tools) | +| `SYNC_CONFIGS` | daily notes, plugins, and app | Obsidian settings categories synced to the server (see [Daily notes](#daily-notes)); `none` disables | +| `INSTANCE_NAME` | `vault-cortex` | Names this install's container, Compose project and volumes. Set it before the first start to run another vault on the same server (see [Run more than one vault](#run-more-than-one-vault)) | +| `DEVICE_NAME` | `vault-cortex` | The device name that labels this container's changes in Obsidian's sync log | +| `SYNC_MODE` | `bidirectional` | Sync direction: `bidirectional`, `pull-only` (edits made on the server stay on the server and are never uploaded), or `mirror-remote` (Obsidian Sync overwrites edits made on the server, so the server always matches your vault). An invalid value stops the container at boot. | +| `CONFLICT_STRATEGY` | `merge` | Obsidian Sync conflict resolution: `merge` integrates changes automatically; `conflict` writes a separate conflict file. | +| `SYNC_EXCLUDED_FOLDERS` | _(empty)_ | Folders to leave out of sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Empty excludes nothing. | +| `SYNC_FILE_TYPES` | _(empty)_ | Attachment types to sync, comma-separated — the same toggles as Obsidian's Sync → Selective sync. **Valid values:** `image`, `audio`, `video`, `pdf`, `unsupported`. **Empty (default):** `image,audio,video,pdf`. The text files Vault Cortex reads (CSV, JSON, TXT, HTML, scripts and other UTF-8 files) fall under `unsupported`, Obsidian Sync's name for its **Sync all other types** category; SVG syncs as `image`, and notes, canvases, and bases always sync. To read those text files, set it to `image,audio,video,pdf,unsupported` (the value replaces the default list, so keep the four defaults) and turn on **Sync all other types** in Obsidian's Sync settings on the device that has them. An invalid value is skipped with a warning in the logs, and the default types sync instead. | +| `DAILY_NOTES_FOLDER` | from vault config | Sets the daily notes folder (see [Daily notes](#daily-notes)) | +| `DAILY_NOTES_FORMAT` | from vault config | Sets the daily note filename format (see [Daily notes](#daily-notes)) | +| `TRUST_PROXY_HOPS` | `0` | Set `1` when a tunnel or reverse proxy fronts the server — OAuth rate limiting then buckets by the real client IP, not the proxy's | +| `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 | +| `STORAGE_ROOT` | — | One directory for everything that must persist, for hosting platforms that allow a single volume (see **Single persistent volume** under [Setup](#setup)). Must not contain `*`, `?`, or `[`. | All settings are documented in `.env.example` and in the [Configuration](../../README.md#configuration) section of the main README. diff --git a/deploy/render/README.md b/deploy/render/README.md index 9b4811c75..6dfbb2f8f 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -257,26 +257,26 @@ release lists what changed since the last one, and an entry marked The Blueprint sets these; change them under the service's **Environment** tab (each change triggers a redeploy): -| Variable | Value | What it does | -| ----------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `STORAGE_ROOT` | `/persist` | Where the disk is mounted — the vault, search index and its models, Sync device state, and logs live under it. Must not contain `*`, `?`, or `[`. Leave as is. | -| `PORT` | `8000` | The port the image listens on. Leave as is. | -| `DEVICE_NAME` | `vault-cortex` | The device name that labels this container's changes in Obsidian's sync log. | -| `TRUST_PROXY_HOPS` | `2` | Render's network puts two proxies between a visitor and the container; this lets the server see the visitor's real address in its logs and rate limits. | -| `MCP_AUTH_TOKEN` | generated | Your MCP client's token. Change it here to rotate it — every connected client re-authorizes. | -| `OBSIDIAN_AUTH_TOKEN` | from setup page | Obsidian Sync login. Set by the setup page on first deploy (written to the disk). To sign in with a different account, set a new token here — it always overrides the file on the disk. | -| `VAULT_NAME` | yours | The vault this container syncs. | -| `VAULT_PASSWORD` | yours / empty | End-to-end encryption password, if your vault has one. | -| `TZ` | yours / empty | Timezone for daily notes, task due dates, and memory timestamps. Empty means UTC. | -| `MEMORY_ENABLED` | `true` | The About Me/ memory layer and its tools. Set `false` to hide them and skip creating the folder. | -| `EMBEDDING_ENABLED` | `true` | Semantic search. Set `false` to skip the models and use keyword search only — the container fits in much less memory. | -| `READONLY_MODE` | `false` | Set `true` to hide every tool that changes the vault — clients can only read and search. | -| `FILE_TOOLS_ENABLED` | `true` | `vault_read_file` and `vault_list_files`. Set `false` when Obsidian Sync has attachment syncing off. | -| `SYNC_MODE` | `bidirectional` | Sync direction: `bidirectional`, `pull-only` (edits made on the server stay on the server and are never uploaded), or `mirror-remote` (Obsidian Sync overwrites edits made on the server, so the server always matches your vault). An invalid value stops the container at boot. | -| `CONFLICT_STRATEGY` | `merge` | Obsidian Sync conflict resolution: `merge` integrates changes automatically; `conflict` writes a separate conflict file. | -| `SYNC_EXCLUDED_FOLDERS` | _(empty)_ | Folders to leave out of sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Empty excludes nothing. | -| `SYNC_FILE_TYPES` | _(empty)_ | Attachment types to sync, comma-separated — the same toggles as Obsidian's Sync → Selective sync. **Valid values:** `image`, `audio`, `video`, `pdf`, `unsupported`. **Empty (default):** `image,audio,video,pdf`. The text files Vault Cortex reads (CSV, JSON, TXT, XML, LOG, YAML) fall under `unsupported`, Obsidian Sync's name for its **Sync all other types** category; SVG syncs as `image`, and notes, canvases, and bases always sync. To read those text files, set it to `image,audio,video,pdf,unsupported` (the value replaces the default list, so keep the four defaults) and turn on **Sync all other types** in Obsidian's Sync settings on the device that has them. An invalid value is skipped with a warning in the logs, and the default types sync instead. | -| `LOG_DIR` | derived | Log files, kept 90 days on the disk — the platform's own log viewer keeps only the last 7 days on Hobby plans. Set `none` to keep logs in the platform viewer only. | +| Variable | Value | What it does | +| ----------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STORAGE_ROOT` | `/persist` | Where the disk is mounted — the vault, search index and its models, Sync device state, and logs live under it. Must not contain `*`, `?`, or `[`. Leave as is. | +| `PORT` | `8000` | The port the image listens on. Leave as is. | +| `DEVICE_NAME` | `vault-cortex` | The device name that labels this container's changes in Obsidian's sync log. | +| `TRUST_PROXY_HOPS` | `2` | Render's network puts two proxies between a visitor and the container; this lets the server see the visitor's real address in its logs and rate limits. | +| `MCP_AUTH_TOKEN` | generated | Your MCP client's token. Change it here to rotate it — every connected client re-authorizes. | +| `OBSIDIAN_AUTH_TOKEN` | from setup page | Obsidian Sync login. Set by the setup page on first deploy (written to the disk). To sign in with a different account, set a new token here — it always overrides the file on the disk. | +| `VAULT_NAME` | yours | The vault this container syncs. | +| `VAULT_PASSWORD` | yours / empty | End-to-end encryption password, if your vault has one. | +| `TZ` | yours / empty | Timezone for daily notes, task due dates, and memory timestamps. Empty means UTC. | +| `MEMORY_ENABLED` | `true` | The About Me/ memory layer and its tools. Set `false` to hide them and skip creating the folder. | +| `EMBEDDING_ENABLED` | `true` | Semantic search. Set `false` to skip the models and use keyword search only — the container fits in much less memory. | +| `READONLY_MODE` | `false` | Set `true` to hide every tool that changes the vault — clients can only read and search. | +| `FILE_TOOLS_ENABLED` | `true` | `vault_read_file` and `vault_list_files`. Set `false` when Obsidian Sync has attachment syncing off. | +| `SYNC_MODE` | `bidirectional` | Sync direction: `bidirectional`, `pull-only` (edits made on the server stay on the server and are never uploaded), or `mirror-remote` (Obsidian Sync overwrites edits made on the server, so the server always matches your vault). An invalid value stops the container at boot. | +| `CONFLICT_STRATEGY` | `merge` | Obsidian Sync conflict resolution: `merge` integrates changes automatically; `conflict` writes a separate conflict file. | +| `SYNC_EXCLUDED_FOLDERS` | _(empty)_ | Folders to leave out of sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Empty excludes nothing. | +| `SYNC_FILE_TYPES` | _(empty)_ | Attachment types to sync, comma-separated — the same toggles as Obsidian's Sync → Selective sync. **Valid values:** `image`, `audio`, `video`, `pdf`, `unsupported`. **Empty (default):** `image,audio,video,pdf`. The text files Vault Cortex reads (CSV, JSON, TXT, HTML, scripts and other UTF-8 files) fall under `unsupported`, Obsidian Sync's name for its **Sync all other types** category; SVG syncs as `image`, and notes, canvases, and bases always sync. To read those text files, set it to `image,audio,video,pdf,unsupported` (the value replaces the default list, so keep the four defaults) and turn on **Sync all other types** in Obsidian's Sync settings on the device that has them. An invalid value is skipped with a warning in the logs, and the default types sync instead. | +| `LOG_DIR` | derived | Log files, kept 90 days on the disk — the platform's own log viewer keeps only the last 7 days on Hobby plans. Set `none` to keep logs in the platform viewer only. | `SYNC_MODE`, `CONFLICT_STRATEGY`, `SYNC_EXCLUDED_FOLDERS`, and `SYNC_FILE_TYPES` are the settings most worth changing on a hosted instance; diff --git a/package-lock.json b/package-lock.json index d466e88c9..424b95fef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "express": "5.3.0", "express-rate-limit": "8.7.3", "gray-matter": "4.0.3", + "htmlparser2": "12.0.0", "luxon": "3.7.2", "pdfjs-dist": "6.5.438", "picomatch": "4.0.7", @@ -5890,6 +5891,73 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5930,7 +5998,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -6936,6 +7003,28 @@ "dev": true, "license": "MIT" }, + "node_modules/htmlparser2": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", diff --git a/package.json b/package.json index 39d792976..9fd75183c 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "express": "5.3.0", "express-rate-limit": "8.7.3", "gray-matter": "4.0.3", + "htmlparser2": "12.0.0", "luxon": "3.7.2", "pdfjs-dist": "6.5.438", "picomatch": "4.0.7", diff --git a/scripts/generate-dockerhub-readme.ts b/scripts/generate-dockerhub-readme.ts index 55b35acc5..482ea8e78 100644 --- a/scripts/generate-dockerhub-readme.ts +++ b/scripts/generate-dockerhub-readme.ts @@ -48,6 +48,7 @@ const EXCLUDED_H2 = new Set([ "Hybrid Search", "Memory", "Tasks", + "Files", "Data Integrity", "Authentication", "Development", @@ -60,6 +61,13 @@ const EXCLUDED_H2 = new Set([ const REPLACED_H2 = new Set(["License"]) +/** Docker Hub truncates a repository description past this many bytes. */ +const DOCKER_HUB_CAP_BYTES = 25_000 + +/** The run warns 1,000 bytes before the cap, so a README edit that brings the + * output close to it is flagged while there is still room to trim. */ +const SIZE_WARNING_BYTES = DOCKER_HUB_CAP_BYTES - 1_000 + // Sections where we keep only the heading, intro sentence, and tables — strip // verbose paragraphs that add bulk without adding Docker Hub value const COMPACT_H2 = new Set(["Properties", "Configuration", "Deployment Options"]) @@ -81,10 +89,7 @@ const rewriteUrls = (line: string): string => { result = result.replace(/\]\(\.\/assets\//g, `](${GITHUB_RAW}/assets/`) for (const dir of DIRECTORY_PATHS) { - result = result.replace( - new RegExp(`\\]\\(\\./${dir.replace("/", "\\/")}`, "g"), - `](${GITHUB_REPO}/tree/main/${dir}`, - ) + result = result.replaceAll(`](./${dir}`, `](${GITHUB_REPO}/tree/main/${dir}`) } result = result.replace(/\]\(\.\//g, `](${GITHUB_REPO}/blob/main/`) @@ -104,8 +109,7 @@ const compressTableRow = (line: string): string => { return cells.join(" | ").trim() } -const isContentsLine = (line: string): boolean => - line.startsWith("**Contents**") || line.startsWith("**Contents** —") +const isContentsLine = (line: string): boolean => line.startsWith("**Contents**") /** DeepWiki documents the source repo, not the image, and the badge's raw * GitHub URL would push DOCKERHUB.md past Docker Hub's 25000-byte cap. */ @@ -126,10 +130,9 @@ const generate = (): void => { let insideFence = false let insideDetails = false let skipSection = false - let skipLevel = 0 let insertedNotice = false let compactSection = false - let compactTableDone = false + let droppingPostTableProse = false for (const line of lines) { if (line.startsWith("```")) { @@ -158,62 +161,53 @@ const generate = (): void => { const heading = parseHeading(line) - if (heading) { - if (heading.level === 2) { - if (EXCLUDED_H2.has(heading.text)) { - skipSection = true - skipLevel = 2 - if (heading.text === "Quick Start") { - output.push(...QUICKSTART_REDIRECT) - } - continue - } - - if (REPLACED_H2.has(heading.text)) { - skipSection = true - skipLevel = 2 - output.push(...LICENSE_REPLACEMENT) - continue + // An H2 decides whether its whole section, H3 subsections included, is + // skipped, and only the next H2 ends a skip + if (heading?.level === 2) { + if (EXCLUDED_H2.has(heading.text)) { + skipSection = true + if (heading.text === "Quick Start") { + output.push(...QUICKSTART_REDIRECT) } - - skipSection = false - skipLevel = 0 - compactSection = COMPACT_H2.has(heading.text) - compactTableDone = false - } else if (heading.level === 3) { - if (skipSection && skipLevel === 2) continue - // Compact sections keep only the heading, intro, and tables — an H3 - // subsection's prose is already dropped below, so dropping the - // heading too avoids publishing an empty section title. - if (compactSection) continue + continue } - } - if (skipSection) { - if (heading && heading.level <= skipLevel) { - skipSection = false - skipLevel = 0 - } else { + if (REPLACED_H2.has(heading.text)) { + skipSection = true + output.push(...LICENSE_REPLACEMENT) continue } + + skipSection = false + compactSection = COMPACT_H2.has(heading.text) + droppingPostTableProse = false } - // In compact sections, keep heading + intro + tables, drop post-table prose + if (skipSection) continue + + // Compact sections keep only the heading, intro, and tables — an H3 + // subsection's prose is already dropped below, so dropping the + // heading too avoids publishing an empty section title. + if (heading?.level === 3 && compactSection) continue + + // In compact sections, keep heading + intro + tables. The first non-blank + // line after a table starts dropping prose, until the next table row. if (compactSection && !heading) { const isTableRow = line.startsWith("|") if (isTableRow) { - compactTableDone = false - } else if (compactTableDone) { + droppingPostTableProse = false + } else if (droppingPostTableProse) { continue } else if (line.trim() !== "") { - const lastTableIdx = output.findLastIndex((outputLine) => outputLine.startsWith("|")) - const lastHeadingIdx = output.findLastIndex( + const lastTableIndex = output.findLastIndex((outputLine) => outputLine.startsWith("|")) + const lastHeadingIndex = output.findLastIndex( (outputLine) => parseHeading(outputLine) !== undefined, ) + const followsSectionTable = lastTableIndex > lastHeadingIndex - if (lastTableIdx > lastHeadingIdx) { - compactTableDone = true + if (followsSectionTable) { + droppingPostTableProse = true continue } } @@ -254,8 +248,10 @@ const generate = (): void => { writeFileSync(resolvePath("DOCKERHUB.md"), content, "utf-8") console.log(`generated DOCKERHUB.md (${byteCount} bytes)`) - if (byteCount > 24_000) { - console.warn(`warning: output is ${byteCount} bytes — Docker Hub truncates at 25000`) + if (byteCount > SIZE_WARNING_BYTES) { + console.warn( + `warning: output is ${byteCount} bytes — Docker Hub truncates at ${DOCKER_HUB_CAP_BYTES}`, + ) } } diff --git a/src/__tests__/integration/server-error-contracts.test.ts b/src/__tests__/integration/server-error-contracts.test.ts index cbbb78bf5..6618f5783 100644 --- a/src/__tests__/integration/server-error-contracts.test.ts +++ b/src/__tests__/integration/server-error-contracts.test.ts @@ -2229,6 +2229,48 @@ describe("malformed canvas", () => { }) }) +// ── Binary file ────────────────────────────────────────────── + +describe("binary file", () => { + it("vault_read_file on a file holding a NUL byte", async () => { + const filePath = "Archive.zip" + const fileFullPath = join(serverVaultPath, filePath) + // A ZIP local file header, whose version and flag fields hold NUL bytes + await writeFile(fileFullPath, Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00])) + onTestFinished(() => rm(fileFullPath)) + + const result = await callTool({ client, name: "vault_read_file", args: { path: filePath } }) + expect(result).toEqual({ + content: [ + { + type: "text", + text: '[Error]: unsupported file type ".zip": "Archive.zip" exists (8 bytes) and holds a NUL byte in its first 8,000 bytes (binary data or UTF-16 text). Readable types: images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and UTF-8 text files', + }, + ], + isError: true, + }) + }) + + it("vault_read_file on a file whose bytes are not UTF-8", async () => { + const filePath = "Latin1.html" + const fileFullPath = join(serverVaultPath, filePath) + // 0xE9 is "é" in Latin-1 and never a complete UTF-8 sequence + await writeFile(fileFullPath, Buffer.from([0x3c, 0x70, 0x3e, 0xe9])) + onTestFinished(() => rm(fileFullPath)) + + const result = await callTool({ client, name: "vault_read_file", args: { path: filePath } }) + expect(result).toEqual({ + content: [ + { + type: "text", + text: '[Error]: not valid UTF-8: "Latin1.html" cannot be returned as text', + }, + ], + isError: true, + }) + }) +}) + // ── Whitespace-only task text ──────────────────────────────── describe("whitespace-only task text", () => { diff --git a/src/utils/__tests__/decode-text-buffer.test.ts b/src/utils/__tests__/decode-text-buffer.test.ts new file mode 100644 index 000000000..25291e8c9 --- /dev/null +++ b/src/utils/__tests__/decode-text-buffer.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest" +import { decodeTextBuffer } from "../decode-text-buffer.js" + +/** `length` bytes of ASCII "a" with a NUL byte at `nulIndex`. */ +const buildBufferWithNulAt = (params: { length: number; nulIndex: number }): Buffer => { + const buffer = Buffer.alloc(params.length, "a") + buffer[params.nulIndex] = 0 + return buffer +} + +describe("decodeTextBuffer", () => { + it("decodes UTF-8 text, multibyte characters included", () => { + expect(decodeTextBuffer(Buffer.from("café — ✓\n", "utf8"))).toEqual({ + status: "text", + text: "café — ✓\n", + }) + }) + + it("decodes an empty buffer as empty text", () => { + expect(decodeTextBuffer(Buffer.alloc(0))).toEqual({ status: "text", text: "" }) + }) + + it("calls bytes binary when a NUL byte is the first byte", () => { + expect(decodeTextBuffer(buildBufferWithNulAt({ length: 10, nulIndex: 0 }))).toEqual({ + status: "binary", + }) + }) + + it("calls bytes binary when a NUL byte is the last byte of the first 8,000", () => { + expect(decodeTextBuffer(buildBufferWithNulAt({ length: 9_000, nulIndex: 7_999 }))).toEqual({ + status: "binary", + }) + }) + + it("decodes bytes whose first NUL byte sits just past the first 8,000", () => { + const buffer = buildBufferWithNulAt({ length: 8_001, nulIndex: 8_000 }) + + expect(decodeTextBuffer(buffer)).toEqual({ + status: "text", + text: `${"a".repeat(8_000)}\u0000`, + }) + }) + + it("calls BOM-less UTF-16 text binary, since its ASCII characters carry NUL bytes", () => { + expect(decodeTextBuffer(Buffer.from("hi", "utf16le"))).toEqual({ status: "binary" }) + }) + + it("calls bytes that are not UTF-8 invalid", () => { + // 0xE9 is "é" in Latin-1 and never a complete UTF-8 sequence + expect(decodeTextBuffer(Buffer.from([0x63, 0x61, 0x66, 0xe9]))).toEqual({ + status: "invalid-utf8", + }) + }) + + it("calls an encoded UTF-16 surrogate invalid, as a strict UTF-8 decoder does", () => { + expect(decodeTextBuffer(Buffer.from([0xed, 0xa0, 0x80]))).toEqual({ status: "invalid-utf8" }) + }) + + it("drops a leading byte-order mark", () => { + const withByteOrderMark = Buffer.from([0xef, 0xbb, 0xbf, 0x68, 0x69]) + + expect(decodeTextBuffer(withByteOrderMark)).toEqual({ status: "text", text: "hi" }) + }) +}) diff --git a/src/utils/__tests__/fs.test.ts b/src/utils/__tests__/fs.test.ts index 09c9a95a5..546a5208a 100644 --- a/src/utils/__tests__/fs.test.ts +++ b/src/utils/__tests__/fs.test.ts @@ -1,5 +1,15 @@ import { describe, it, expect, onTestFinished, vi } from "vitest" -import { chmod, mkdtemp, readdir, realpath, rm, symlink, writeFile, mkdir } from "node:fs/promises" +import { + chmod, + mkdtemp, + readdir, + realpath, + rm, + symlink, + truncate, + writeFile, + mkdir, +} from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" @@ -8,6 +18,7 @@ import { tmpdir } from "node:os" vi.mock("node:fs/promises", { spy: true }) import { readFileOrNull, + readFileTextPrefix, readdirOrNull, realpathOrNull, fileExists, @@ -73,13 +84,117 @@ describe("readFileOrNull", () => { ) }) +describe("readFileTextPrefix", () => { + it("returns the whole text of a file shorter than maxBytes", async () => { + const dir = await makeTempDir() + const path = join(dir, "short.log") + await writeFile(path, "short line\n", "utf8") + + expect(await readFileTextPrefix({ path, maxBytes: 100 })).toBe("short line\n") + }) + + it("returns an empty string for an empty file", async () => { + const dir = await makeTempDir() + const path = join(dir, "empty.log") + await writeFile(path, "", "utf8") + + expect(await readFileTextPrefix({ path, maxBytes: 100 })).toBe("") + }) + + it("reads the start of a file too large to load whole", async () => { + const dir = await makeTempDir() + const path = join(dir, "huge.log") + await writeFile(path, "first line\n", "utf8") + // A sparse 3 GiB file takes no disk space, and readFile refuses any file over 2 GiB + await truncate(path, 3 * 1024 ** 3) + + expect(await readFileTextPrefix({ path, maxBytes: 11 })).toBe("first line\n") + }) + + it("returns an empty string for a maxBytes below one byte", async () => { + const dir = await makeTempDir() + const path = join(dir, "short.log") + await writeFile(path, "short line\n", "utf8") + + expect(await readFileTextPrefix({ path, maxBytes: 0 })).toBe("") + expect(await readFileTextPrefix({ path, maxBytes: 0.5 })).toBe("") + }) + + it("reads whole bytes only when maxBytes has a fraction", async () => { + const dir = await makeTempDir() + const path = join(dir, "short.log") + await writeFile(path, "short line\n", "utf8") + + expect(await readFileTextPrefix({ path, maxBytes: 2.5 })).toBe("sh") + }) + + it("returns only the first maxBytes of a longer file", async () => { + const dir = await makeTempDir() + const path = join(dir, "long.log") + await writeFile(path, "0123456789abcdef", "utf8") + + expect(await readFileTextPrefix({ path, maxBytes: 10 })).toBe("0123456789") + }) + + it("leaves out a character the cut splits instead of ending in a replacement character", async () => { + const dir = await makeTempDir() + const path = join(dir, "accent.log") + // "é" is the two bytes 0xC3 0xA9, so a 3-byte cut keeps only its first byte + await writeFile(path, "abé", "utf8") + + expect(await readFileTextPrefix({ path, maxBytes: 3 })).toBe("ab") + }) + + it("replaces an unfinished character at the end of the file, as readFile does", async () => { + const dir = await makeTempDir() + const path = join(dir, "unfinished.log") + // 0xE2 0x82 starts a three-byte character that the file never finishes + await writeFile(path, Buffer.from([0x61, 0x62, 0xe2, 0x82])) + + expect(await readFileTextPrefix({ path, maxBytes: 100 })).toBe("ab\uFFFD") + }) + + it("replaces an unfinished character at the end of a file exactly maxBytes long", async () => { + const dir = await makeTempDir() + const path = join(dir, "exact.log") + // The file ends at the limit, so nothing is cut and readFile's decoding applies + await writeFile(path, Buffer.from([0x61, 0x62, 0xe2, 0x82])) + + expect(await readFileTextPrefix({ path, maxBytes: 4 })).toBe("ab\uFFFD") + }) + + it("keeps a byte-order mark and replaces an invalid byte, as readFile does", async () => { + const dir = await makeTempDir() + const path = join(dir, "latin1.log") + // 0xEF 0xBB 0xBF is the UTF-8 byte-order mark; 0xE9 alone is never valid UTF-8 + await writeFile(path, Buffer.from([0xef, 0xbb, 0xbf, 0x61, 0xe9, 0x62])) + + expect(await readFileTextPrefix({ path, maxBytes: 100 })).toBe("\uFEFFa\uFFFDb") + }) + + it("rejects when no file exists at the path", async () => { + const dir = await makeTempDir() + const missingPath = join(dir, "missing.log") + + // Node reports ENOENT as errno -2 on both Linux and macOS + const missingFileError = Object.assign( + new Error(`ENOENT: no such file or directory, open '${missingPath}'`), + { errno: -2, code: "ENOENT", syscall: "open", path: missingPath }, + ) + + await expect(readFileTextPrefix({ path: missingPath, maxBytes: 100 })).rejects.toThrow( + missingFileError, + ) + }) +}) + describe("readdirOrNull", () => { it("returns recursive directory entries when the directory exists", async () => { const dir = await makeTempDir() await mkdir(join(dir, "sub")) await writeFile(join(dir, "sub", "a.md"), "x", "utf8") const entries = await readdirOrNull(dir) - const names = entries?.map((entry) => entry.name).sort() + const names = entries?.map((entry) => entry.name).toSorted() expect(names).toEqual(["a.md", "sub"]) }) diff --git a/src/utils/decode-text-buffer.ts b/src/utils/decode-text-buffer.ts new file mode 100644 index 000000000..bf20b12c4 --- /dev/null +++ b/src/utils/decode-text-buffer.ts @@ -0,0 +1,26 @@ +import { isUtf8 } from "node:buffer" + +/** How many leading bytes are searched for a NUL byte. Git calls a file binary + * when its first 8,000 bytes hold one (FIRST_FEW_BYTES in xdiff-interface.c): + * https://github.com/git/git/blob/master/xdiff-interface.c */ +export const NUL_SEARCH_WINDOW_BYTES = 8_000 + +type DecodedTextBuffer = + | Readonly<{ status: "text"; text: string }> + | Readonly<{ status: "binary" }> + | Readonly<{ status: "invalid-utf8" }> + +// Decoding is stateless outside streaming mode, so one decoder serves every call. +const utf8Decoder = new TextDecoder("utf-8") + +/** Decodes bytes as UTF-8 text. + * - A NUL byte within the first NUL_SEARCH_WINDOW_BYTES marks them binary. + * - Any byte sequence that is not UTF-8 marks them invalid. + * - A leading byte-order mark is dropped, as TextDecoder does by default. */ +export const decodeTextBuffer = (buffer: Uint8Array): DecodedTextBuffer => { + const holdsEarlyNulByte = buffer.subarray(0, NUL_SEARCH_WINDOW_BYTES).includes(0) + + if (holdsEarlyNulByte) return { status: "binary" } + if (!isUtf8(buffer)) return { status: "invalid-utf8" } + return { status: "text", text: utf8Decoder.decode(buffer) } +} diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 2fbb724da..b95936fc1 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -1,5 +1,8 @@ import { lstat, readFile, readdir, realpath, stat } from "node:fs/promises" +import { createReadStream } from "node:fs" import type { Dirent, Stats } from "node:fs" +import { buffer as streamToBuffer } from "node:stream/consumers" +import { StringDecoder } from "node:string_decoder" import { isErrnoException } from "./is-errno-exception.js" /** True for the errors that mean nothing is reachable at a path: the entry is @@ -22,6 +25,33 @@ export const readFileOrNull = async (path: string): Promise => { } } +/** Decodes as UTF-8 the way readFile does: an invalid byte becomes a + * replacement character and a byte-order mark stays. Reads at most one byte + * past maxBytes, and leaves out a character that the maxBytes cut splits. */ +export const readFileTextPrefix = async (params: { + path: string + maxBytes: number +}): Promise => { + const byteLimit = Math.floor(params.maxBytes) + + if (byteLimit < 1) return "" + + // end is inclusive, so the stream stops after byteLimit + 1 bytes. The extra + // byte shows whether the file goes on past byteLimit. + const readBytes = await streamToBuffer( + createReadStream(params.path, { start: 0, end: byteLimit }), + ) + const decoder = new StringDecoder("utf8") + const fileContinuesPastLimit = readBytes.length > byteLimit + + // At a cut, write() holds back the bytes of the character the cut splits, so + // the text never ends in a replacement character. At the end of the file, + // end() turns an unfinished character into one, as readFile does. + return fileContinuesPastLimit + ? decoder.write(readBytes.subarray(0, byteLimit)) + : decoder.end(readBytes) +} + /** Stats a path, returning null instead of throwing when nothing exists there * (ENOENT) or a parent is a file (ENOTDIR). Any other error propagates. */ export const statOrNull = async (path: string): Promise => { 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 1db2fccc5..11beb8803 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 @@ -486,7 +486,7 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — non-markdown files across the whole vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads only images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and the text formats .svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({})\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, HTML (.html/.htm), script (.py/.sh/.ps1), and .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and any UTF-8 text file.", "inputSchema": { "type": "object", "properties": { @@ -1075,14 +1075,14 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text format returns the file byte-exact.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the type (audio, archives, …) is not readable; the error names the readable types plus the file's existence and size\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): one text block holding the file content verbatim.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes the content of canvases and PDFs, the visible text of HTML pages (.html/.htm), scripts (.py/.sh/.ps1), and the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text file returns the file byte-exact, except that a leading byte-order mark is dropped.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the file holds binary data (audio, archives, UTF-16 text, …); the error names the readable types plus the file's existence and size. Ask for a UTF-8 copy of UTF-16 text\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Any other UTF-8 text file (.svg, .json, .csv, .html, .py, no extension, …): one text block holding the file content.", "inputSchema": { "type": "object", "properties": { "path": { "type": "string", "minLength": 1, - "description": "Vault-relative path to the file, including its extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." + "description": "Vault-relative path to the file, including any extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." }, "raw": { "description": "Return a canvas's or PDF's alternative form. Changes nothing for text formats.", @@ -1313,7 +1313,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across vault notes and file content (canvas, PDF, and text files), ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. When only keyword matching ran (search_mode \"fts\"), at most 100 notes and 100 files come back; with \"hybrid\", at most 200 notes and 200 files.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when only keyword matching ran (search_mode \"fts\").\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", + "description": "Hybrid search across vault notes and file content (canvas, PDF, the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, the visible text of HTML pages, and .py/.sh/.ps1 scripts), ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. With search_mode \"fts\", at most 100 notes and 100 files come back; with \"hybrid\", at most 200 notes and 200 files. Each file's first 100 KiB of text is searched; a page or script over 16 MiB is found by its name only; scripts match by keyword only.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when search_mode is \"fts\".\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json index bc0b159c7..949c830ae 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 @@ -488,7 +488,7 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — non-markdown files across the whole vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads only images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and the text formats .svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({})\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, HTML (.html/.htm), script (.py/.sh/.ps1), and .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and any UTF-8 text file.", "inputSchema": { "type": "object", "properties": { @@ -1016,14 +1016,14 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text format returns the file byte-exact.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the type (audio, archives, …) is not readable; the error names the readable types plus the file's existence and size\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): one text block holding the file content verbatim.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes the content of canvases and PDFs, the visible text of HTML pages (.html/.htm), scripts (.py/.sh/.ps1), and the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text file returns the file byte-exact, except that a leading byte-order mark is dropped.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the file holds binary data (audio, archives, UTF-16 text, …); the error names the readable types plus the file's existence and size. Ask for a UTF-8 copy of UTF-16 text\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Any other UTF-8 text file (.svg, .json, .csv, .html, .py, no extension, …): one text block holding the file content.", "inputSchema": { "type": "object", "properties": { "path": { "type": "string", "minLength": 1, - "description": "Vault-relative path to the file, including its extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." + "description": "Vault-relative path to the file, including any extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." }, "raw": { "description": "Return a canvas's or PDF's alternative form. Changes nothing for text formats.", @@ -1254,7 +1254,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across vault notes and file content (canvas, PDF, and text files), ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. When only keyword matching ran (search_mode \"fts\"), at most 100 notes and 100 files come back; with \"hybrid\", at most 200 notes and 200 files.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when only keyword matching ran (search_mode \"fts\").\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", + "description": "Hybrid search across vault notes and file content (canvas, PDF, the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, the visible text of HTML pages, and .py/.sh/.ps1 scripts), ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. With search_mode \"fts\", at most 100 notes and 100 files come back; with \"hybrid\", at most 200 notes and 200 files. Each file's first 100 KiB of text is searched; a page or script over 16 MiB is found by its name only; scripts match by keyword only.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when search_mode is \"fts\".\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json index 114e1399b..2b5ed6209 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 @@ -488,7 +488,7 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — non-markdown files across the whole vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads only images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and the text formats .svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({})\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, HTML (.html/.htm), script (.py/.sh/.ps1), and .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and any UTF-8 text file.", "inputSchema": { "type": "object", "properties": { @@ -1077,14 +1077,14 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text format returns the file byte-exact.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the type (audio, archives, …) is not readable; the error names the readable types plus the file's existence and size\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): one text block holding the file content verbatim.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes the content of canvases and PDFs, the visible text of HTML pages (.html/.htm), scripts (.py/.sh/.ps1), and the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text file returns the file byte-exact, except that a leading byte-order mark is dropped.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the file holds binary data (audio, archives, UTF-16 text, …); the error names the readable types plus the file's existence and size. Ask for a UTF-8 copy of UTF-16 text\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Any other UTF-8 text file (.svg, .json, .csv, .html, .py, no extension, …): one text block holding the file content.", "inputSchema": { "type": "object", "properties": { "path": { "type": "string", "minLength": 1, - "description": "Vault-relative path to the file, including its extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." + "description": "Vault-relative path to the file, including any extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." }, "raw": { "description": "Return a canvas's or PDF's alternative form. Changes nothing for text formats.", @@ -1315,7 +1315,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across vault notes and file content (canvas, PDF, and text files), ranked by relevance. Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. At most 100 notes and 100 files come back.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before matching; a query left with no terms returns no results.\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", + "description": "Full-text search across vault notes and file content (canvas, PDF, the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, the visible text of HTML pages, and .py/.sh/.ps1 scripts), ranked by relevance. Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. At most 100 notes and 100 files come back. Each file's first 100 KiB of text is searched; a page or script over 16 MiB is found by its name only.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before matching; a query left with no terms returns no results.\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json index 60c1b75ba..bd50f2085 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 @@ -1228,7 +1228,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across vault notes, ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. When only keyword matching ran (search_mode \"fts\"), at most 100 notes come back; with \"hybrid\", at most 200 notes.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when only keyword matching ran (search_mode \"fts\").\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (top-level folder), type, kind, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is always \"note\". created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", + "description": "Hybrid search across vault notes, ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. With search_mode \"fts\", at most 100 notes come back; with \"hybrid\", at most 200 notes.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when search_mode is \"fts\".\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (top-level folder), type, kind, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is always \"note\". created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json index 31fd496e8..80046ebb7 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 @@ -406,7 +406,7 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — non-markdown files across the whole vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads only images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and the text formats .svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({})\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, HTML (.html/.htm), script (.py/.sh/.ps1), and .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and any UTF-8 text file.", "inputSchema": { "type": "object", "properties": { @@ -935,14 +935,14 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text format returns the file byte-exact.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the type (audio, archives, …) is not readable; the error names the readable types plus the file's existence and size\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): one text block holding the file content verbatim.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes the content of canvases and PDFs, the visible text of HTML pages (.html/.htm), scripts (.py/.sh/.ps1), and the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text file returns the file byte-exact, except that a leading byte-order mark is dropped.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the file holds binary data (audio, archives, UTF-16 text, …); the error names the readable types plus the file's existence and size. Ask for a UTF-8 copy of UTF-16 text\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Any other UTF-8 text file (.svg, .json, .csv, .html, .py, no extension, …): one text block holding the file content.", "inputSchema": { "type": "object", "properties": { "path": { "type": "string", "minLength": 1, - "description": "Vault-relative path to the file, including its extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." + "description": "Vault-relative path to the file, including any extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." }, "raw": { "description": "Return a canvas's or PDF's alternative form. Changes nothing for text formats.", @@ -1173,7 +1173,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across vault notes and file content (canvas, PDF, and text files), ranked by relevance. Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. At most 100 notes and 100 files come back.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before matching; a query left with no terms returns no results.\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", + "description": "Full-text search across vault notes and file content (canvas, PDF, the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, the visible text of HTML pages, and .py/.sh/.ps1 scripts), ranked by relevance. Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. At most 100 notes and 100 files come back. Each file's first 100 KiB of text is searched; a page or script over 16 MiB is found by its name only.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before matching; a query left with no terms returns no results.\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json index 01e0815ff..f403b4d4c 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 @@ -1086,7 +1086,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across vault notes, ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. When only keyword matching ran (search_mode \"fts\"), at most 100 notes come back; with \"hybrid\", at most 200 notes.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when only keyword matching ran (search_mode \"fts\").\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (top-level folder), type, kind, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is always \"note\". created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", + "description": "Hybrid search across vault notes, ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. With search_mode \"fts\", at most 100 notes come back; with \"hybrid\", at most 200 notes.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when search_mode is \"fts\".\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (top-level folder), type, kind, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is always \"note\". created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json index 53c3bf7d4..76e9af991 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 @@ -405,7 +405,7 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — non-markdown files across the whole vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads only images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and the text formats .svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({})\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, HTML (.html/.htm), script (.py/.sh/.ps1), and .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and any UTF-8 text file.", "inputSchema": { "type": "object", "properties": { @@ -934,14 +934,14 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text format returns the file byte-exact.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the type (audio, archives, …) is not readable; the error names the readable types plus the file's existence and size\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): one text block holding the file content verbatim.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes the content of canvases and PDFs, the visible text of HTML pages (.html/.htm), scripts (.py/.sh/.ps1), and the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text file returns the file byte-exact, except that a leading byte-order mark is dropped.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the file holds binary data (audio, archives, UTF-16 text, …); the error names the readable types plus the file's existence and size. Ask for a UTF-8 copy of UTF-16 text\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Any other UTF-8 text file (.svg, .json, .csv, .html, .py, no extension, …): one text block holding the file content.", "inputSchema": { "type": "object", "properties": { "path": { "type": "string", "minLength": 1, - "description": "Vault-relative path to the file, including its extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." + "description": "Vault-relative path to the file, including any extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." }, "raw": { "description": "Return a canvas's or PDF's alternative form. Changes nothing for text formats.", @@ -1172,7 +1172,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across vault notes and file content (canvas, PDF, and text files), ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. When only keyword matching ran (search_mode \"fts\"), at most 100 notes and 100 files come back; with \"hybrid\", at most 200 notes and 200 files.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when only keyword matching ran (search_mode \"fts\").\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", + "description": "Hybrid search across vault notes and file content (canvas, PDF, the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, the visible text of HTML pages, and .py/.sh/.ps1 scripts), ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. With search_mode \"fts\", at most 100 notes and 100 files come back; with \"hybrid\", at most 200 notes and 200 files. Each file's first 100 KiB of text is searched; a page or script over 16 MiB is found by its name only; scripts match by keyword only.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when search_mode is \"fts\".\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json index e35f4ae3b..189e049f5 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 @@ -488,7 +488,7 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — non-markdown files across the whole vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads only images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and the text formats .svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({})\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, HTML (.html/.htm), script (.py/.sh/.ps1), and .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and any UTF-8 text file.", "inputSchema": { "type": "object", "properties": { @@ -1077,14 +1077,14 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text format returns the file byte-exact.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the type (audio, archives, …) is not readable; the error names the readable types plus the file's existence and size\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): one text block holding the file content verbatim.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes the content of canvases and PDFs, the visible text of HTML pages (.html/.htm), scripts (.py/.sh/.ps1), and the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text file returns the file byte-exact, except that a leading byte-order mark is dropped.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the file holds binary data (audio, archives, UTF-16 text, …); the error names the readable types plus the file's existence and size. Ask for a UTF-8 copy of UTF-16 text\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Any other UTF-8 text file (.svg, .json, .csv, .html, .py, no extension, …): one text block holding the file content.", "inputSchema": { "type": "object", "properties": { "path": { "type": "string", "minLength": 1, - "description": "Vault-relative path to the file, including its extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." + "description": "Vault-relative path to the file, including any extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." }, "raw": { "description": "Return a canvas's or PDF's alternative form. Changes nothing for text formats.", @@ -1315,7 +1315,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across vault notes and file content (canvas, PDF, and text files), ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. When only keyword matching ran (search_mode \"fts\"), at most 100 notes and 100 files come back; with \"hybrid\", at most 200 notes and 200 files.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when only keyword matching ran (search_mode \"fts\").\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", + "description": "Hybrid search across vault notes and file content (canvas, PDF, the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, the visible text of HTML pages, and .py/.sh/.ps1 scripts), ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. With search_mode \"fts\", at most 100 notes and 100 files come back; with \"hybrid\", at most 200 notes and 200 files. Each file's first 100 KiB of text is searched; a page or script over 16 MiB is found by its name only; scripts match by keyword only.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when search_mode is \"fts\".\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property; vault_update_properties edits it), body_tags (#tags in the note's text, one per spelling; vault_replace_in_note edits them; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json index 16d3d9791..c346e6232 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 @@ -165,7 +165,7 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — non-markdown files across the whole vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads only images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and the text formats .svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({})\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, HTML (.html/.htm), script (.py/.sh/.ps1), and .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and any UTF-8 text file.", "inputSchema": { "type": "object", "properties": { @@ -653,14 +653,14 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text format returns the file byte-exact.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the type (audio, archives, …) is not readable; the error names the readable types plus the file's existence and size\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): one text block holding the file content verbatim.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes the content of canvases and PDFs, the visible text of HTML pages (.html/.htm), scripts (.py/.sh/.ps1), and the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text file returns the file byte-exact, except that a leading byte-order mark is dropped.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the file holds binary data (audio, archives, UTF-16 text, …); the error names the readable types plus the file's existence and size. Ask for a UTF-8 copy of UTF-16 text\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Any other UTF-8 text file (.svg, .json, .csv, .html, .py, no extension, …): one text block holding the file content.", "inputSchema": { "type": "object", "properties": { "path": { "type": "string", "minLength": 1, - "description": "Vault-relative path to the file, including its extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." + "description": "Vault-relative path to the file, including any extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." }, "raw": { "description": "Return a canvas's or PDF's alternative form. Changes nothing for text formats.", @@ -795,7 +795,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across vault notes and file content (canvas, PDF, and text files), ranked by relevance. Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. At most 100 notes and 100 files come back.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before matching; a query left with no terms returns no results.\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property), body_tags (#tags in the note's text, one per spelling; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", + "description": "Full-text search across vault notes and file content (canvas, PDF, the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, the visible text of HTML pages, and .py/.sh/.ps1 scripts), ranked by relevance. Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. At most 100 notes and 100 files come back. Each file's first 100 KiB of text is searched; a page or script over 16 MiB is found by its name only.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before matching; a query left with no terms returns no results.\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property), body_tags (#tags in the note's text, one per spelling; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json index 14db80ecc..57f26ff0b 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 @@ -708,7 +708,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across vault notes, ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. When only keyword matching ran (search_mode \"fts\"), at most 100 notes come back; with \"hybrid\", at most 200 notes.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when only keyword matching ran (search_mode \"fts\").\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property), body_tags (#tags in the note's text, one per spelling; absent when none), folder (top-level folder), type, kind, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is always \"note\". created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", + "description": "Hybrid search across vault notes, ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. With search_mode \"fts\", at most 100 notes come back; with \"hybrid\", at most 200 notes.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when search_mode is \"fts\".\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property), body_tags (#tags in the note's text, one per spelling; absent when none), folder (top-level folder), type, kind, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is always \"note\". created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+embedding-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+embedding-off.json index c90e0960d..387281626 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 @@ -130,7 +130,7 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — non-markdown files across the whole vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads only images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and the text formats .svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({})\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, HTML (.html/.htm), script (.py/.sh/.ps1), and .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and any UTF-8 text file.", "inputSchema": { "type": "object", "properties": { @@ -558,14 +558,14 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text format returns the file byte-exact.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the type (audio, archives, …) is not readable; the error names the readable types plus the file's existence and size\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): one text block holding the file content verbatim.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes the content of canvases and PDFs, the visible text of HTML pages (.html/.htm), scripts (.py/.sh/.ps1), and the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text file returns the file byte-exact, except that a leading byte-order mark is dropped.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the file holds binary data (audio, archives, UTF-16 text, …); the error names the readable types plus the file's existence and size. Ask for a UTF-8 copy of UTF-16 text\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Any other UTF-8 text file (.svg, .json, .csv, .html, .py, no extension, …): one text block holding the file content.", "inputSchema": { "type": "object", "properties": { "path": { "type": "string", "minLength": 1, - "description": "Vault-relative path to the file, including its extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." + "description": "Vault-relative path to the file, including any extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." }, "raw": { "description": "Return a canvas's or PDF's alternative form. Changes nothing for text formats.", @@ -700,7 +700,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Full-text search across vault notes and file content (canvas, PDF, and text files), ranked by relevance. Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. At most 100 notes and 100 files come back.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before matching; a query left with no terms returns no results.\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property), body_tags (#tags in the note's text, one per spelling; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", + "description": "Full-text search across vault notes and file content (canvas, PDF, the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, the visible text of HTML pages, and .py/.sh/.ps1 scripts), ranked by relevance. Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\nExample: vault_search({ query: \"deployment\", filters: { properties: { status: \"active\" } } })\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. At most 100 notes and 100 files come back. Each file's first 100 KiB of text is searched; a page or script over 16 MiB is found by its name only.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before matching; a query left with no terms returns no results.\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property), body_tags (#tags in the note's text, one per spelling; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"fts\" — keyword-only ranking), and reranked (always false in keyword-only mode). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off.json index dae8b78b7..a385a4e80 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 @@ -613,7 +613,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across vault notes, ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. When only keyword matching ran (search_mode \"fts\"), at most 100 notes come back; with \"hybrid\", at most 200 notes.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when only keyword matching ran (search_mode \"fts\").\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property), body_tags (#tags in the note's text, one per spelling; absent when none), folder (top-level folder), type, kind, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is always \"note\". created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", + "description": "Hybrid search across vault notes, ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. With search_mode \"fts\", at most 100 notes come back; with \"hybrid\", at most 200 notes.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when search_mode is \"fts\".\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property), body_tags (#tags in the note's text, one per spelling; absent when none), folder (top-level folder), type, kind, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is always \"note\". created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off.json index 4b6c9822b..1b78110f6 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 @@ -129,7 +129,7 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — non-markdown files across the whole vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads only images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and the text formats .svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({})\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, HTML (.html/.htm), script (.py/.sh/.ps1), and .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and any UTF-8 text file.", "inputSchema": { "type": "object", "properties": { @@ -557,14 +557,14 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text format returns the file byte-exact.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the type (audio, archives, …) is not readable; the error names the readable types plus the file's existence and size\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): one text block holding the file content verbatim.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes the content of canvases and PDFs, the visible text of HTML pages (.html/.htm), scripts (.py/.sh/.ps1), and the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text file returns the file byte-exact, except that a leading byte-order mark is dropped.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the file holds binary data (audio, archives, UTF-16 text, …); the error names the readable types plus the file's existence and size. Ask for a UTF-8 copy of UTF-16 text\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Any other UTF-8 text file (.svg, .json, .csv, .html, .py, no extension, …): one text block holding the file content.", "inputSchema": { "type": "object", "properties": { "path": { "type": "string", "minLength": 1, - "description": "Vault-relative path to the file, including its extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." + "description": "Vault-relative path to the file, including any extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." }, "raw": { "description": "Return a canvas's or PDF's alternative form. Changes nothing for text formats.", @@ -699,7 +699,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across vault notes and file content (canvas, PDF, and text files), ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. When only keyword matching ran (search_mode \"fts\"), at most 100 notes and 100 files come back; with \"hybrid\", at most 200 notes and 200 files.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when only keyword matching ran (search_mode \"fts\").\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property), body_tags (#tags in the note's text, one per spelling; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", + "description": "Hybrid search across vault notes and file content (canvas, PDF, the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, the visible text of HTML pages, and .py/.sh/.ps1 scripts), ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. With search_mode \"fts\", at most 100 notes and 100 files come back; with \"hybrid\", at most 200 notes and 200 files. Each file's first 100 KiB of text is searched; a page or script over 16 MiB is found by its name only; scripts match by keyword only.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when search_mode is \"fts\".\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property), body_tags (#tags in the note's text, one per spelling; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json b/src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json index c4e2d303f..0d046c0ba 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 @@ -164,7 +164,7 @@ { "name": "vault_list_files", "title": "List Files", - "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({}) — non-markdown files across the whole vault\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads only images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and the text formats .svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base.", + "description": "List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.\n\nExample: vault_list_files({})\nExample: vault_list_files({ folder: \"attachments\" })\nExample: vault_list_files({ extensions: [\".png\", \".jpg\"], limit: 20 })\n\nWhen to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, HTML (.html/.htm), script (.py/.sh/.ps1), and .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.\n\nBehavior: A visible folder containing no files — or one that doesn't exist, or a path that names a file — returns an empty listing, not an error. Use the folder's exact letter case; a different case can find nothing. Hidden (dot-prefixed) files and folders, .obsidian/ among them, never appear.\n\nErrors:\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — the folder is absolute, escapes the vault, names the vault root itself (e.g. \".\"), or is hidden like \".obsidian\"; use a vault-relative folder outside hidden folders, and omit folder to list the whole vault.\n- \"ENAMETOOLONG: …\" or another filesystem error — the message names the folder it could not read, and what to do; until then, pass a folder outside it.\n\nReturns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is \"(none)\" for a file without one), extension_counts (counts keyed by extension, e.g. { \".png\": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).\n\nReadable types: vault_read_file reads images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and any UTF-8 text file.", "inputSchema": { "type": "object", "properties": { @@ -652,14 +652,14 @@ { "name": "vault_read_file", "title": "Read File", - "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes canvas, PDF, and text-format content, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text format returns the file byte-exact.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the type (audio, archives, …) is not readable; the error names the readable types plus the file's existence and size\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): one text block holding the file content verbatim.", + "description": "Read a non-markdown vault file in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.\n\nExample: vault_read_file({ path: \"attachments/diagram.png\" }) — the image itself, shrunk when too large\nExample: vault_read_file({ path: \"Boards/Roadmap.canvas\" }) — a readable outline of the canvas\nExample: vault_read_file({ path: \"exports/data.json\" }) — the file content as text\nExample: vault_read_file({ path: \"exports/big.csv\", limit: 500 }) — the first 500 lines\nExample: vault_read_file({ path: \"papers/research.pdf\" }) — structured text with title, headings, and links\n\nWhen to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF. Find the files a note links to (with byte sizes) via vault_get_outgoing_links. Browse a folder's files via vault_list_files. vault_search also indexes the content of canvases and PDFs, the visible text of HTML pages (.html/.htm), scripts (.py/.sh/.ps1), and the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, but not images or other files. For .md notes use vault_read_note. To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total.\n\nParameters:\n- raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first 5 pages are rendered, and later pages are readable only as text, without raw.\n- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap.\n\nBehavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue (\"data.csv — lines 51–100 of 400 (continue with start_line: 101)\"); an unpaged read of a text file returns the file byte-exact, except that a leading byte-order mark is dropped.\n\nErrors:\n- \"not a file: … is a markdown note\" — the path ends in .md; read notes with vault_read_note\n- \"file not found\" — no file exists at that path (a folder is not a file); discover valid paths via vault_list_files\n- \"absolute path blocked\" / \"path traversal blocked\" / \"hidden path blocked\" — pass a vault-relative path with no dot-prefixed part\n- \"file too large\" — the file exceeds the file-size cap (MAX_FILE_BYTES, default 50 MiB), which the server's operator can raise\n- \"text output too large\" — a text file, canvas, or PDF renders past the text output cap; page it with start_line and limit, or reduce limit when a single window overflows\n- \"start line past the end\" — start_line exceeds the file's line count; the error states the total, so retry with a smaller start_line\n- \"line range is not available\" (start_line or limit on an image, or on a PDF read with raw: true) / \"raw source is not available for images\" (raw on an image) — drop that input\n- \"not valid UTF-8\" — the file's bytes aren't UTF-8 text; ask for a UTF-8 copy\n- \"invalid .canvas JSON\" — the canvas file is empty or not valid JSON; set raw: true to read its source as text\n- \"PDF has no extractable text\" — the PDF contains no text (scanned or image-only); the error states the page count. Set raw: true to render pages as images instead\n- \"PDF is password-protected\" — read an unprotected copy instead\n- \"PDF is damaged or not a PDF\" / \"could not decode image\" — the PDF does not parse, or the image is empty, damaged, not an image despite its extension, or over about 268 million pixels; any other PDF or image error comes back in the reading library's own words. Replace or re-export the file\n- \"PDF page rendering failed\" — read it without raw: true to get its text instead; if that read also finds no text, this tool can't read the PDF\n- \"image cannot be fitted into …\" — the image is over the image output budget even after shrinking; ask the server's operator to raise MAX_IMAGE_OUTPUT_BYTES\n- \"ENAMETOOLONG: …\" or another filesystem error — the message says what to do\n- \"file changed while reading\" — retry the read\n- \"PDF reader could not start\" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server\n- \"unsupported file type …\" — the file holds binary data (audio, archives, UTF-16 text, …); the error names the readable types plus the file's existence and size. Ask for a UTF-8 copy of UTF-16 text\n\nReturns: by file type:\n- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (49152 bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed.\n- Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels.\n- PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page).\n- Any other UTF-8 text file (.svg, .json, .csv, .html, .py, no extension, …): one text block holding the file content.", "inputSchema": { "type": "object", "properties": { "path": { "type": "string", "minLength": 1, - "description": "Vault-relative path to the file, including its extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." + "description": "Vault-relative path to the file, including any extension (e.g. \"attachments/photo.png\", \"Boards/Roadmap.canvas\"). Must NOT end in \".md\". Use the exact letter case." }, "raw": { "description": "Return a canvas's or PDF's alternative form. Changes nothing for text formats.", @@ -794,7 +794,7 @@ { "name": "vault_search", "title": "Search Notes", - "description": "Hybrid search across vault notes and file content (canvas, PDF, and text files), ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. When only keyword matching ran (search_mode \"fts\"), at most 100 notes and 100 files come back; with \"hybrid\", at most 200 notes and 200 files.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when only keyword matching ran (search_mode \"fts\").\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property), body_tags (#tags in the note's text, one per spelling; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", + "description": "Hybrid search across vault notes and file content (canvas, PDF, the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, the visible text of HTML pages, and .py/.sh/.ps1 scripts), ranked by combined keyword and semantic relevance. Semantic matching finds notes even when exact keywords differ — \"career aspirations\" finds notes about \"goals\" and \"targets\". Unquoted terms use implicit AND with stemming (\"running\" also matches \"run\"); wrap in double quotes for exact phrases; punctuated terms (vault-cortex, deploy/local) are matched as exact adjacent-word phrases.\n\nFilters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:\n- folder: a whole folder, subfolders included: \"Projects\" covers \"Projects/Archive\" but not \"ProjectsOld/\", ignoring ASCII letter case\n- tags: every listed tag must match, in the tags property or as a #tag in the body, in any letter case; \"project\" also matches \"project/a\"\n- type: the type property, matched exactly, letter case included\n- related: every listed link must appear in the related property, written as it is there, brackets and alias included\n- properties: keys and text values match exactly, letter case included; values compare by exact type — pass a number as a number, not \"4\". Exception: true and false compare as 1 and 0, so true or 1 matches a checked checkbox and the number 1. A list property matches when any element equals the value.\n- created / modified: bounds compare whole calendar days in the server's time zone (returned created and modified values show its UTC offset). A note whose created property is missing or not an ISO date never matches a created filter\n\nExample: vault_search({ query: \"how the server watches for file changes\" })\nExample: vault_search({ query: \"kubernetes networking\", filters: { tags: [\"reference\"] } })\nExample: vault_search({ query: \"meeting notes\", filters: { type: \"meeting\", folder: \"Work\" } })\nExample: vault_search({ query: \"decision\", filters: { modified: { after: \"2026-06-30\" } } }) — matching notes touched in July or later\n\nWhen to use: The primary discovery tool for content-based queries, optionally constrained by metadata.\nPrefer vault_search_by_tag for tag-only queries without text. Prefer vault_search_by_folder for browsing a folder. Prefer vault_search_by_property for metadata-only queries. Prefer vault_recent_notes for time-based browsing.\n\nBehavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error. With search_mode \"fts\", at most 100 notes and 100 files come back; with \"hybrid\", at most 200 notes and 200 files. Each file's first 100 KiB of text is searched; a page or script over 16 MiB is found by its name only; scripts match by keyword only.\n- Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before keyword matching; a query left with no terms is matched by meaning only, or finds nothing when search_mode is \"fts\".\n\nErrors:\n- \"invalid created.on date: …\" or \"… is not a calendar date\" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form\n- \"tag must not be empty …\" — a tags entry is only \"#\"; pass the tag name\n- \"[SqliteError]: …\" / \"[IndexIntegrityError]: …\" — the index failed, not the call; the message says what to do\n\nReturns: JSON with results array (path, title, snippet, score, tags (the tags property), body_tags (#tags in the note's text, one per spelling; absent when none), folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes), total (results returned, not all matches), search_mode (\"hybrid\" when vector embeddings contributed, \"fts\" when only keyword matching ran), and reranked (true when a second ranking pass reordered the results). A higher score is more relevant. kind is \"note\" or, for non-markdown content, \"file\", which also carries extension (e.g. \".pdf\"). created is omitted, not null, when a result has none; type is null when the result has no type property; bytes is the on-disk size. With include_leading_callout, each result also carries leading_callout (the opening callout, { type, title, body }) when present.", "inputSchema": { "type": "object", "properties": { diff --git a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts index 0c1e0a412..c6a572799 100644 --- a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts +++ b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts @@ -1465,7 +1465,7 @@ describe("vault_search description reflects EMBEDDING_ENABLED", () => { const description = findSearchDescription(registerWithConfig({})) const openingSentence = description.slice(0, description.indexOf(". ") + 1) expect(openingSentence).toBe( - "Hybrid search across vault notes and file content (canvas, PDF, and text files), ranked by combined keyword and semantic relevance.", + "Hybrid search across vault notes and file content (canvas, PDF, the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, the visible text of HTML pages, and .py/.sh/.ps1 scripts), ranked by combined keyword and semantic relevance.", ) expect(description).toContain("career aspirations") expect(description).toContain("search_mode") @@ -1489,9 +1489,11 @@ describe("vault_search description reflects FILE_TOOLS_ENABLED", () => { // Search indexes file content only while file tools are on, so with them // off the description must not mention file content, the extension field, - // or file results. Both embedding variants share this wording. + // or file results. Both embedding variants share this wording; the Behavior + // line tests below pin where they differ. const FILE_RESULT_WORDING = { - searchedScope: "search across vault notes and file content (canvas, PDF, and text files)", + searchedScope: + "search across vault notes and file content (canvas, PDF, the text formats .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base, the visible text of HTML pages, and .py/.sh/.ps1 scripts)", filtersLeadIn: "Filters — all conditions AND-combine with each other and the text query; any filter but folder returns notes only:", resultFields: `results array (path, title, snippet, score, ${TAG_FIELDS_WORDING}, folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes`, @@ -1548,25 +1550,29 @@ describe("vault_search description reflects FILE_TOOLS_ENABLED", () => { }) }) -describe("vault_search result cap", () => { +describe("vault_search Behavior line: result cap and file content limits", () => { const BEHAVIOR_LEAD = "Behavior: Results can lag a file change by a few seconds. No matches returns an empty results array with total 0, not an error." + // Stated only while file content is searched (FILE_TOOLS_ENABLED on) + const FILE_CONTENT_LIMITS = + "Each file's first 100 KiB of text is searched; a page or script over 16 MiB is found by its name only" it.each([ { - label: "bounds both modes for notes and files by default", + label: "bounds both modes for notes and files and states the file limits by default", env: {}, - expectedLine: `${BEHAVIOR_LEAD} When only keyword matching ran (search_mode "fts"), at most 100 notes and 100 files come back; with "hybrid", at most 200 notes and 200 files.`, + expectedLine: `${BEHAVIOR_LEAD} With search_mode "fts", at most 100 notes and 100 files come back; with "hybrid", at most 200 notes and 200 files. ${FILE_CONTENT_LIMITS}; scripts match by keyword only.`, }, { label: "bounds both modes for notes only when file tools are off", env: { FILE_TOOLS_ENABLED: "false" }, - expectedLine: `${BEHAVIOR_LEAD} When only keyword matching ran (search_mode "fts"), at most 100 notes come back; with "hybrid", at most 200 notes.`, + expectedLine: `${BEHAVIOR_LEAD} With search_mode "fts", at most 100 notes come back; with "hybrid", at most 200 notes.`, }, { - label: "states only the keyword bound for notes and files when embeddings are off", + label: + "states only the keyword bound for notes and files, and the file limits, when embeddings are off", env: { EMBEDDING_ENABLED: "false" }, - expectedLine: `${BEHAVIOR_LEAD} At most 100 notes and 100 files come back.`, + expectedLine: `${BEHAVIOR_LEAD} At most 100 notes and 100 files come back. ${FILE_CONTENT_LIMITS}.`, }, { label: "states only the keyword bound for notes when embeddings and file tools are off", @@ -2973,21 +2979,37 @@ describe("file tool handlers", () => { }) }) - it("rejects an unsupported extension naming the readable types", async () => { + it("rejects a file holding NUL bytes, naming the readable types", async () => { const { vault, readAsset } = await setupAssetHarness() - await writeFile(join(vault, "song.mp3"), "xxxx", "utf8") + // An ID3v2.4 tag header, which opens most MP3 files and holds NUL bytes + await writeFile(join(vault, "song.mp3"), Buffer.from([0x49, 0x44, 0x33, 0x04, 0x00, 0x00])) const result = await readAsset({ path: "song.mp3" }) expect(result).toEqual({ isError: true, content: [ { type: "text", - text: '[Error]: unsupported file type ".mp3": "song.mp3" exists (4 bytes). Readable types: images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base)', + text: '[Error]: unsupported file type ".mp3": "song.mp3" exists (6 bytes) and holds a NUL byte in its first 8,000 bytes (binary data or UTF-16 text). Readable types: images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and UTF-8 text files', }, ], }) }) + it("returns an HTML page's source as text", async () => { + const { vault, readAsset } = await setupAssetHarness() + const pageSource = "\n

Runbook

\n\n" + await writeFile(join(vault, "runbook.html"), pageSource, "utf8") + const result = await readAsset({ path: "runbook.html" }) + expect(result).toEqual({ content: [{ type: "text", text: pageSource }] }) + }) + + it("reads a note whose .MD extension is uppercase as text, until notes with that extension are supported", async () => { + const { vault, readAsset } = await setupAssetHarness() + await writeFile(join(vault, "Note.MD"), "# Upper\n", "utf8") + const result = await readAsset({ path: "Note.MD" }) + expect(result).toEqual({ content: [{ type: "text", text: "# Upper\n" }] }) + }) + it("rejects a .md path without touching the note", async () => { const { vault, readAsset } = await setupAssetHarness() await writeFile(join(vault, "note.md"), "# A note\n", "utf8") @@ -3449,7 +3471,7 @@ describe("DISABLED_TOOLS", () => { it("vault_list_files contrasts itself with the note discovery tools while all three are served", () => { expect(listFilesWhenToUse("")).toBe( - "When to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.", + "When to use: discovering what files exist before reading them with vault_read_file. vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, HTML (.html/.htm), script (.py/.sh/.ps1), and .txt/.csv/.json/.xml/.svg/.log/.yaml/.yml/.base files, so this is the discovery surface for everything else. For the files one specific note links to, prefer vault_get_outgoing_links.", ) }) diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts index a1459723d..ed5540cab 100644 --- a/src/vault-mcp/mcp-core/tools/asset-tools.ts +++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts @@ -2,9 +2,11 @@ import { z } from "zod" import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" -import { assetOperations } from "../../vault-operations/asset-operations.js" +import { assetOperations, MAX_TEXT_OUTPUT_BYTES } from "../../vault-operations/asset-operations.js" import type { AssetReadResult } from "../../vault-operations/asset-operations.js" import type { FittedImage } from "../../../utils/fit-image-to-byte-budget.js" +import { getExtensionsOfKind } from "../../obsidian-markdown/file-kinds.js" +import type { FileKind } from "../../obsidian-markdown/file-kinds.js" import { TOOL_NAMES } from "../tool-registry.js" import type { ToolRegistrationContext } from "./tool-helpers.js" import { @@ -77,6 +79,10 @@ const formatAssetReadResult = (result: AssetReadResult): CallToolResult["content } } +/** A file kind's extensions as the tool text writes them (".html/.htm"), read + * from the file-kind map so the text follows it when a type is added. */ +const formatExtensionsOfKind = (kind: FileKind): string => getExtensionsOfKind(kind).join("/") + export const registerAssetTools = ({ registerTool, safeHandler, @@ -94,8 +100,12 @@ export const registerAssetTools = ({ TOOL_NAMES.VAULT_SEARCH_BY_FOLDER, TOOL_NAMES.VAULT_SEARCH, ].every(isToolEnabled) + const imageExtensions = formatExtensionsOfKind("image") + const htmlExtensions = formatExtensionsOfKind("html") + const scriptExtensions = formatExtensionsOfKind("script") + const textExtensions = formatExtensionsOfKind("text") const listFilesContrastSentence = noteDiscoveryToolsServed - ? " vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, and text-format files, so this is the discovery surface for everything else." + ? ` vault_list_notes and vault_search_by_folder cover only markdown notes, and beyond notes vault_search indexes only canvas, PDF, HTML (${htmlExtensions}), script (${scriptExtensions}), and ${textExtensions} files, so this is the discovery surface for everything else.` : " This is the discovery surface for every file that is not a markdown note." registerTool( @@ -110,13 +120,13 @@ Example: vault_read_file({ path: "exports/data.json" }) — the file content as Example: vault_read_file({ path: "exports/big.csv", limit: 500 }) — the first 500 lines Example: vault_read_file({ path: "papers/research.pdf" }) — structured text with title, headings, and links -When to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF.${whenToolEnabledText("vault_get_outgoing_links", " Find the files a note links to (with byte sizes) via vault_get_outgoing_links.")}${whenToolEnabledText("vault_list_files", " Browse a folder's files via vault_list_files.")}${whenToolEnabledText("vault_search", " vault_search also indexes canvas, PDF, and text-format content, but not images or other files.")}${whenToolEnabledText("vault_read_note", " For .md notes use vault_read_note.")} To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total. +When to use: whenever a note references a file you need to actually see or read — an embedded diagram, a linked canvas, data file, or PDF.${whenToolEnabledText("vault_get_outgoing_links", " Find the files a note links to (with byte sizes) via vault_get_outgoing_links.")}${whenToolEnabledText("vault_list_files", " Browse a folder's files via vault_list_files.")}${whenToolEnabledText("vault_search", ` vault_search also indexes the content of canvases and PDFs, the visible text of HTML pages (${htmlExtensions}), scripts (${scriptExtensions}), and the text formats ${textExtensions}, but not images or other files.`)}${whenToolEnabledText("vault_read_note", " For .md notes use vault_read_note.")} To check a large file's line count before reading it whole, request start_line: 1 with limit: 1 — one line plus the total. Parameters: - raw: true switches a canvas or PDF to its other form. A canvas returns its exact JSON source (geometry, ids, colors) instead of the outline. A PDF returns each page rendered as an image instead of extracted text, showing layout, diagrams, tables, and formatting; only the first ${config.maxPdfRenderPages} pages are rendered, and later pages are readable only as text, without raw. -- start_line and limit page any text result as a line window. The text output cap (a fixed 100 KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. +- start_line and limit page any text result as a line window. The text output cap (a fixed ${MAX_TEXT_OUTPUT_BYTES / 1024} KiB) applies to each window, so one very long line can still overflow it; paging never gets around the file-size cap. -Behavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue ("data.csv — lines 51–100 of 400 (continue with start_line: 101)"); an unpaged read of a text format returns the file byte-exact. +Behavior: A paged window comes back with \\n line endings and no trailing newline, preceded by a metadata line stating the window, the total line count, and where to continue ("data.csv — lines 51–100 of 400 (continue with start_line: 101)"); an unpaged read of a text file returns the file byte-exact, except that a leading byte-order mark is dropped. Errors: - "not a file: … is a markdown note" — the path ends in .md${whenToolEnabledText("vault_read_note", "; read notes with vault_read_note")} @@ -136,19 +146,19 @@ ${PATH_SAFETY_ERROR_ENTRY} ${FILESYSTEM_ERROR_ENTRY} - "file changed while reading" — retry the read - "PDF reader could not start" — the server's PDF reader failed to load; retry once, then ask the server's operator to restart the server -- "unsupported file type …" — the type (audio, archives, …) is not readable; the error names the readable types plus the file's existence and size +- "unsupported file type …" — the file holds binary data (audio, archives, UTF-16 text, …); the error names the readable types plus the file's existence and size. Ask for a UTF-8 copy of UTF-16 text Returns: by file type: -- Images (.png/.jpg/.jpeg/.gif/.webp): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (${config.maxImageOutputBytes} bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed. +- Images (${imageExtensions}): an image content block plus a one-line metadata text block. The image is downscaled and recompressed when it exceeds the image output budget (${config.maxImageOutputBytes} bytes) or has a longer side over 1568 pixels; an animated GIF is reduced to its first frame when recompressed. - Canvas (.canvas): one text block holding a readable markdown outline — groups, node content in reading order, and a connections list with edge labels. - PDFs (.pdf): one text block of structured text with document metadata — title, page count, heading hierarchy, code blocks and inline code, page separators, and a links footer; with raw: true, a metadata text block followed by alternating image and text blocks (one pair per page). -- Text formats (.svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base): one text block holding the file content verbatim.`, +- Any other UTF-8 text file (.svg, .json, .csv, .html, .py, no extension, …): one text block holding the file content.`, inputSchema: { path: z .string() .min(1) .describe( - 'Vault-relative path to the file, including its extension (e.g. "attachments/photo.png", "Boards/Roadmap.canvas"). Must NOT end in ".md". Use the exact letter case.', + 'Vault-relative path to the file, including any extension (e.g. "attachments/photo.png", "Boards/Roadmap.canvas"). Must NOT end in ".md". Use the exact letter case.', ), raw: z .boolean() @@ -235,7 +245,7 @@ Returns: by file type: title: "List Files", description: `List non-markdown files in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts. -Example: vault_list_files({}) — non-markdown files across the whole vault +Example: vault_list_files({}) Example: vault_list_files({ folder: "attachments" }) Example: vault_list_files({ extensions: [".png", ".jpg"], limit: 20 }) @@ -249,7 +259,7 @@ ${LISTING_FILESYSTEM_ERROR_ENTRY}. Returns: JSON with files (array of { path, extension, bytes }, sorted by path; extension is "(none)" for a file without one), extension_counts (counts keyed by extension, e.g. { ".png": 3 }, over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit).${whenToolEnabledText( "vault_read_file", - "\n\nReadable types: vault_read_file reads only images (.png/.jpg/.jpeg/.gif/.webp), .canvas, .pdf, and the text formats .svg/.json/.txt/.csv/.xml/.log/.yaml/.yml/.base.", + `\n\nReadable types: vault_read_file reads images (${imageExtensions}), .canvas, .pdf, and any UTF-8 text file.`, )}`, inputSchema: { folder: z diff --git a/src/vault-mcp/mcp-core/tools/search-tools.ts b/src/vault-mcp/mcp-core/tools/search-tools.ts index fe3dda12b..4fbec142c 100644 --- a/src/vault-mcp/mcp-core/tools/search-tools.ts +++ b/src/vault-mcp/mcp-core/tools/search-tools.ts @@ -5,6 +5,11 @@ 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 { getExtensionsOfKind } from "../../obsidian-markdown/file-kinds.js" +import { + MAX_INDEXED_CONTENT_BYTES, + MAX_INDEXED_HTML_OR_SCRIPT_BYTES, +} from "../../search/search-index.js" import { SEARCH_LEG_ROW_CAP } from "../../search/hybrid-search.js" import { createStrictInputObject, @@ -83,10 +88,19 @@ Example: vault_search({ query: "decision", filters: { modified: { after: "2026-0 ]) // File content is searched only while FILE_TOOLS_ENABLED is on, so the text - // that mentions file results follows that setting, not any one tool + // that mentions file results follows that setting, not any one tool. Scripts + // are never embedded, which matters to a caller only while semantic matching runs. + const scriptMatchClause = config.embeddingEnabled ? "; scripts match by keyword only" : "" + const scriptExtensions = getExtensionsOfKind("script").join("/") + const textExtensions = getExtensionsOfKind("text").join("/") + const nameOnlyLimitMiB = MAX_INDEXED_HTML_OR_SCRIPT_BYTES / (1024 * 1024) + const indexedPrefixKiB = MAX_INDEXED_CONTENT_BYTES / 1024 const searchedContentText = config.fileToolsEnabled - ? "vault notes and file content (canvas, PDF, and text files)" + ? `vault notes and file content (canvas, PDF, the text formats ${textExtensions}, the visible text of HTML pages, and ${scriptExtensions} scripts)` : "vault notes" + const fileContentLimitsSentence = config.fileToolsEnabled + ? ` Each file's first ${indexedPrefixKiB} KiB of text is searched; a page or script over ${nameOnlyLimitMiB} MiB is found by its name only${scriptMatchClause}.` + : "" const searchResultFieldsText = config.fileToolsEnabled ? `path, title, snippet, score, ${tagsFieldsText}, folder (a note's top-level folder; a file's full parent folder), type, kind, extension, created, modified, bytes` : `path, title, snippet, score, ${tagsFieldsText}, folder (top-level folder), type, kind, created, modified, bytes` @@ -102,7 +116,7 @@ Example: vault_search({ query: "decision", filters: { modified: { after: "2026-0 ? { stage: "keyword matching", outcome: - 'a query left with no terms is matched by meaning only, or finds nothing when only keyword matching ran (search_mode "fts")', + 'a query left with no terms is matched by meaning only, or finds nothing when search_mode is "fts"', } : { stage: "matching", @@ -117,9 +131,9 @@ Example: vault_search({ query: "decision", filters: { modified: { after: "2026-0 const keywordCapExtent = describeCapExtent(SEARCH_LEG_ROW_CAP) const hybridCapExtent = describeCapExtent(SEARCH_LEG_ROW_CAP * 2) const resultCapSentence = config.embeddingEnabled - ? `When only keyword matching ran (search_mode "fts"), at most ${keywordCapExtent} come back; with "hybrid", at most ${hybridCapExtent}.` + ? `With search_mode "fts", at most ${keywordCapExtent} come back; with "hybrid", at most ${hybridCapExtent}.` : `At most ${keywordCapExtent} come back.` - const searchBehaviorLine = `Behavior: ${INDEX_LAG_LEAD}. No matches returns an empty results array with total 0, not an error. ${resultCapSentence} + const searchBehaviorLine = `Behavior: ${INDEX_LAG_LEAD}. No matches returns an empty results array with total 0, not an error. ${resultCapSentence}${fileContentLimitsSentence} - Malformed query syntax never throws: the words AND, OR, NOT and NEAR in any letter case, and punctuation such as *, ^, parentheses and colons, are dropped before ${emptiedQueryText.stage}; ${emptiedQueryText.outcome}.` const searchErrorsSection = `Errors: - "invalid created.on date: …" or "… is not a calendar date" (any created/modified bound) — pass a real calendar date in YYYY-MM-DD form diff --git a/src/vault-mcp/obsidian-markdown/__tests__/file-kinds.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/file-kinds.test.ts new file mode 100644 index 000000000..433687bd3 --- /dev/null +++ b/src/vault-mcp/obsidian-markdown/__tests__/file-kinds.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest" +import { getExtensionsOfKind, getFileKind, isCanvasPath } from "../file-kinds.js" + +// Test-owned, so a change to the production map has to change this table too +const EXPECTED_KINDS = [ + { extension: ".png", kind: "image" }, + { extension: ".jpg", kind: "image" }, + { extension: ".jpeg", kind: "image" }, + { extension: ".gif", kind: "image" }, + { extension: ".webp", kind: "image" }, + { extension: ".canvas", kind: "canvas" }, + { extension: ".pdf", kind: "pdf" }, + { extension: ".txt", kind: "text" }, + { extension: ".csv", kind: "text" }, + { extension: ".json", kind: "text" }, + { extension: ".xml", kind: "text" }, + { extension: ".svg", kind: "text" }, + { extension: ".log", kind: "text" }, + { extension: ".yaml", kind: "text" }, + { extension: ".yml", kind: "text" }, + { extension: ".base", kind: "text" }, + { extension: ".html", kind: "html" }, + { extension: ".htm", kind: "html" }, + { extension: ".py", kind: "script" }, + { extension: ".sh", kind: "script" }, + { extension: ".ps1", kind: "script" }, +] as const + +/** Uppercases every second letter of an extension: ".canvas" becomes ".cAnVaS". */ +const toMixedCase = (extension: string): string => { + return [...extension] + .map((character, index) => (index % 2 === 0 ? character.toUpperCase() : character)) + .join("") +} + +describe("getFileKind", () => { + it.each(EXPECTED_KINDS)("maps $extension to $kind", ({ extension, kind }) => { + expect(getFileKind(`Folder/file${extension}`)).toBe(kind) + }) + + it.each(EXPECTED_KINDS)("maps an uppercase $extension to $kind", ({ extension, kind }) => { + expect(getFileKind(`Folder/FILE${extension.toUpperCase()}`)).toBe(kind) + }) + + it.each(EXPECTED_KINDS)("maps a mixed-case $extension to $kind", ({ extension, kind }) => { + expect(getFileKind(`Folder/file${toMixedCase(extension)}`)).toBe(kind) + }) + + it("maps a file with no extension to other", () => { + expect(getFileKind("scripts/Makefile")).toBe("other") + }) + + it("maps an extension the map does not list to other", () => { + expect(getFileKind("audio/song.mp3")).toBe("other") + }) + + it("maps a markdown note to other, since notes are not files", () => { + expect(getFileKind("Projects/plan.md")).toBe("other") + }) + + it("reads the kind from the last extension only", () => { + expect(getFileKind("exports/report.pdf.txt")).toBe("text") + }) + + it("maps a dot-prefixed name with no other dot to other", () => { + expect(getFileKind("config/.pdf")).toBe("other") + }) +}) + +describe("isCanvasPath", () => { + it("accepts a canvas in any letter case", () => { + expect(["Board.canvas", "Board.Canvas", "Board.CANVAS"].map(isCanvasPath)).toEqual([ + true, + true, + true, + ]) + }) + + it("rejects a note and other files", () => { + expect(["Board.md", "Board.canvas.md", "Board.json"].map(isCanvasPath)).toEqual([ + false, + false, + false, + ]) + }) +}) + +describe("getExtensionsOfKind", () => { + it("lists the image extensions in order", () => { + expect(getExtensionsOfKind("image")).toEqual([".png", ".jpg", ".jpeg", ".gif", ".webp"]) + }) + + it("lists nothing for other, which covers unlisted extensions", () => { + expect(getExtensionsOfKind("other")).toEqual([]) + }) +}) diff --git a/src/vault-mcp/obsidian-markdown/__tests__/html.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/html.test.ts new file mode 100644 index 000000000..febac3584 --- /dev/null +++ b/src/vault-mcp/obsidian-markdown/__tests__/html.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from "vitest" +import { DateTime } from "luxon" +import { extractHtmlVisibleText } from "../html.js" + +const LARGE_CAP = 1_000_000 + +const extractAll = (html: string): string => extractHtmlVisibleText({ html, maxBytes: LARGE_CAP }) + +describe("extractHtmlVisibleText", () => { + it("returns the visible text of a page, one line per block element", () => { + const page = + "Restart runbook" + + "

Restart

Stop the service first.

" + + "
  • Check logs
  • Start it
" + + expect(extractAll(page)).toBe( + "Restart runbook\nRestart\nStop the service first.\nCheck logs\nStart it", + ) + }) + + it("drops the text of script, style, noscript and template elements", () => { + const page = + "

before

" + + "

after

" + + expect(extractAll(page)).toBe("before\nafter") + }) + + it("drops the raw text of iframe, noembed and noframes fallback content", () => { + const page = + "

shown

" + + "embed fallbackframes fallback" + + expect(extractAll(page)).toBe("shown") + }) + + it("drops an uppercase SCRIPT element's text", () => { + expect(extractAll("

shown

")).toBe("shown") + }) + + it("ignores a stray closing tag for a hidden element that never opened", () => { + expect(extractAll("

VISIBLE

")).toBe("VISIBLE") + }) + + it("keeps a hidden element's text hidden through a closing tag with another name", () => { + expect(extractAll("

VISIBLE

")).toBe("VISIBLE") + }) + + it("keeps text hidden until the outer element of nested hidden elements closes", () => { + expect(extractAll("outer")).toBe("outer") + }) + + it("treats a self-closing script tag as open, as HTML does", () => { + expect(extractAll('

shown

after

')).toBe( + "shown\nafter", + ) + }) + + it("drops an inline SVG's title and desc but keeps its visible text", () => { + const page = + "

Download

download iconan arrow" + + "Q3 total

After

" + + expect(extractAll(page)).toBe("Download\nQ3 total\nAfter") + }) + + it("shows a title after the SVG holding another title closes", () => { + expect(extractAll("iconPage title")).toBe("Page title") + }) + + it("keeps an SVG title hidden after a nested svg closes", () => { + expect(extractAll("tooltip

shown

")).toBe("shown") + }) + + it("shows the rest of the page after a self-closing desc inside an SVG", () => { + expect(extractAll("

shown

")).toBe("shown") + }) + + it("shows a title after a self-closing svg", () => { + expect(extractAll("Page title")).toBe("Page title") + }) + + it("breaks no line for a block element inside a hidden element", () => { + expect(extractAll("

one two

")).toBe("one two") + }) + + it("hides everything after a template that never closes", () => { + expect(extractAll("

shown