diff --git a/.devin/wiki.json b/.devin/wiki.json
index c6f26831..31d484bc 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, just domain knowledge about the Obsidian/Markdown format. Covers: links.ts (link grammar, fence-aware extraction, resolution for wikilinks, markdown links, embeds, frontmatter links, and relative path-from-current-file links — all three of Obsidian's 'New link format' modes); lines.ts (CRLF-safe line splitting, the consolidated fence state machine, classifyLines for identifying fenced regions); frontmatter.ts (gray-matter parse/stringify, frontmatter merge); callouts.ts (leading-callout parser for > [!type] blocks); headings.ts (shared H1-H6 section-span parser used by both read and patch operations); tasks.ts (Tasks-plugin task-line grammar covering emoji signifiers and Dataview inline fields); memory-entries.ts (memory-entry grammar — parses the dated-bullet format of About Me/ memory files into individual entries for vault_memory_recall's entry-granular index); plaintext.ts (strips Obsidian/Markdown syntax to produce plain text for the embedding pipeline). This layer is architecturally significant — it encodes the Obsidian-parity behavior that distinguishes Vault Cortex from simpler file-based integrations.",
+ "purpose": "The pure parser layer in obsidian-markdown/ — no filesystem I/O, just domain knowledge about the Obsidian/Markdown format. Covers: links.ts (link grammar, fence-aware extraction, resolution for wikilinks, markdown links, embeds, frontmatter links, and relative path-from-current-file links — all three of Obsidian's 'New link format' modes); canvas.ts (JSON Canvas 1.0 linearizer — spatial group containment, reading-order node sort, id-resolved edge lists — pure string-to-string despite parsing JSON, same leaf layer); lines.ts (CRLF-safe line splitting, the consolidated fence state machine, classifyLines for identifying fenced regions); frontmatter.ts (gray-matter parse/stringify, frontmatter merge); callouts.ts (leading-callout parser for > [!type] blocks); headings.ts (shared H1-H6 section-span parser used by both read and patch operations); tasks.ts (Tasks-plugin task-line grammar covering emoji signifiers and Dataview inline fields); memory-entries.ts (memory-entry grammar — parses the dated-bullet format of About Me/ memory files into individual entries for vault_memory_recall's entry-granular index); plaintext.ts (strips Obsidian/Markdown syntax to produce plain text for the embedding pipeline). This layer is architecturally significant — it encodes the Obsidian-parity behavior that distinguishes Vault Cortex from simpler file-based integrations.",
"parent": "Architecture",
"page_notes": [
{
@@ -50,7 +50,7 @@
},
{
"title": "Vault Operations",
- "purpose": "The filesystem I/O layer in vault-operations/ that reads, writes, and transforms vault content. Safety primitives: atomic writes (atomicWriteFile temp-then-rename, atomicWriteFileExclusive link-based no-clobber for TOCTOU-safe creates), per-file write locking (file-write-lock.ts — serializing, fail-fast, and multi-file fail-fast modes), path traversal prevention (resolveSafePath resolve + prefix check, toVaultRelativePath normalization), and protected-path enforcement (PROTECTED_PATHS checked after normalization). Covers: vault-filesystem.ts (read/write/list/delete .md files, outline and section reads, protected-path validation); vault-patcher.ts (surgical edits — heading-targeted patch with prepend/append/replace operations, and find-and-replace with optional replace-all); note-mover.ts (move/rename a note and rewrite every vault-wide link that pointed to it, using the link resolution engine from obsidian-markdown/links.ts — preflight-then-commit design with multi-file exclusive locking); memory-store.ts (About Me/ heading-aware read/append/delete with shrink guard, idempotency guard, and ambiguity guard — see Memory Layer page); daily-notes.ts (daily note config reader and path resolver — see Memory Layer page). All functions follow the (params, logger) two-argument pattern.",
+ "purpose": "The filesystem I/O layer in vault-operations/ that reads, writes, and transforms vault content. Asset use-case: asset-operations.ts (per-type read dispatch — image fitting, canvas linearize/raw, strict-UTF-8 text passthrough, structured errors — plus browsing: extension filter, per-extension counts, capped statted slice) composes the vaultFs primitives readAsset (binary read with .md rejection and stat-first size cap), listAssets (folder-scoped non-md walk), and statAssets (bounded-concurrency stat) behind the vault_read_asset/vault_list_assets tools. Safety primitives: atomic writes (atomicWriteFile temp-then-rename, atomicWriteFileExclusive link-based no-clobber for TOCTOU-safe creates), per-file write locking (file-write-lock.ts — serializing, fail-fast, and multi-file fail-fast modes), path traversal prevention (resolveSafePath resolve + prefix check, toVaultRelativePath normalization), and protected-path enforcement (PROTECTED_PATHS checked after normalization). Covers: vault-filesystem.ts (read/write/list/delete .md files, outline and section reads, protected-path validation); vault-patcher.ts (surgical edits — heading-targeted patch with prepend/append/replace operations, and find-and-replace with optional replace-all); note-mover.ts (move/rename a note and rewrite every vault-wide link that pointed to it, using the link resolution engine from obsidian-markdown/links.ts — preflight-then-commit design with multi-file exclusive locking); memory-store.ts (About Me/ heading-aware read/append/delete with shrink guard, idempotency guard, and ambiguity guard — see Memory Layer page); daily-notes.ts (daily note config reader and path resolver — see Memory Layer page). All functions follow the (params, logger) two-argument pattern.",
"parent": "Architecture"
},
{
@@ -69,7 +69,7 @@
},
{
"title": "Tool Reference",
- "purpose": "The MCP tools, now organized in domain group modules under mcp-core/tools/: vault-crud-tools.ts (read, write, patch, replace, delete-span, list, delete, move, update-properties), search-tools.ts (search, search-by-tag, search-by-folder, recent-notes, list-tags, list-property-keys, list-property-values, search-by-property, get-backlinks, get-outgoing-links, find-orphans), task-tools.ts (list-tasks, update-task), memory-tools.ts (get-memory, update-memory, list-memory-files, delete-memory, memory-recall; conditionally registered when MEMORY_ENABLED=true), and daily-note-tools.ts (get-daily-note). vault_list_tasks and vault_update_task in task-tools.ts provide Kanban-aware task management: vault_list_tasks is a vault-wide task index with structured filters (status, 6 date fields, priority, folder/tag/heading/path scope), array params for multi-lane queries, date cascade sorting, and position sorting for board order; vault_update_task applies status, priority, and/or lane changes in a single call — marking done auto-detects the done lane (via **Complete** markers, falling back to 'Done' heading), stamps/removes completion dates, and writes in the user's configured Tasks plugin format (emoji or Dataview). The orchestrator in tool-definitions.ts composes TOOL_NAMES from each group and calls register functions. Each tool description includes Example, When to use, Returns, and Errors sections — optimized against Glama's TDQS (Tool Description Quality Score) model to ensure agents select and invoke the right tool efficiently.",
+ "purpose": "The MCP tools, now organized in domain group modules under mcp-core/tools/: vault-crud-tools.ts (read, write, patch, replace, delete-span, list, delete, move, update-properties), search-tools.ts (search, search-by-tag, search-by-folder, recent-notes, list-tags, list-property-keys, list-property-values, search-by-property, get-backlinks, get-outgoing-links, find-orphans), task-tools.ts (list-tasks, update-task), memory-tools.ts (get-memory, update-memory, list-memory-files, delete-memory, memory-recall; conditionally registered when MEMORY_ENABLED=true), daily-note-tools.ts (get-daily-note), and asset-tools.ts (read-asset — images as MCP image blocks via a sharp fit-to-byte-budget pipeline, .canvas linearized via obsidian-markdown/canvas.ts, text formats verbatim; list-assets — filesystem-backed discovery with folder/extension filters and per-extension counts). vault_list_tasks and vault_update_task in task-tools.ts provide Kanban-aware task management: vault_list_tasks is a vault-wide task index with structured filters (status, 6 date fields, priority, folder/tag/heading/path scope), array params for multi-lane queries, date cascade sorting, and position sorting for board order; vault_update_task applies status, priority, and/or lane changes in a single call — marking done auto-detects the done lane (via **Complete** markers, falling back to 'Done' heading), stamps/removes completion dates, and writes in the user's configured Tasks plugin format (emoji or Dataview). The orchestrator in tool-definitions.ts composes TOOL_NAMES from each group and calls register functions. Each tool description includes Example, When to use, Returns, and Errors sections — optimized against Glama's TDQS (Tool Description Quality Score) model to ensure agents select and invoke the right tool efficiently.",
"parent": "MCP Interface"
},
{
diff --git a/.env.example b/.env.example
index 921b21a3..e0ec46b2 100644
--- a/.env.example
+++ b/.env.example
@@ -101,6 +101,12 @@ TZ=UTC
# Enables polling for the file watcher and rename-based moves across
# the Docker Desktop/WSL2 bridge.
# WINDOWS_MODE=false
+#
+# Largest asset file vault_read_asset will read, in bytes (default: 52428800 = 50 MiB).
+# MAX_ASSET_BYTES=52428800
+# Byte budget for images returned by vault_read_asset, in binary bytes before
+# base64 encoding (default: 49152 = 48 KiB, sized for Claude Code's response cap).
+# MAX_IMAGE_OUTPUT_BYTES=49152
# Enable or disable the memory layer (default: true).
# Set to false to hide memory tools, skip auto-initialization, and omit
# memory references from server metadata. MEMORY_DIR is ignored when false.
diff --git a/AGENTS.md b/AGENTS.md
index 2c0bb897..3c0e32f7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -100,9 +100,10 @@ src/
file-write-lock.ts # Per-file write locks — serializing, fail-fast, and multi-file fail-fast modes (TOCTOU prevention)
map-with-concurrency.ts # Bounded-concurrency async map (batch-based)
describe-error.ts # describeError — message from an unknown throw
- fs.ts # readFileOrNull / readdirOrNull / fileExists (ENOENT-safe)
+ fs.ts # readFileOrNull / readdirOrNull / fileExists / statOrNull (ENOENT-safe)
assert-path-has-extension.ts # Generic path extension assertion (used by note-path validation)
filter-valid-symlinks.ts # Filters out broken symlinks from directory listings
+ fit-image-to-byte-budget.ts # Downscale/recompress an image buffer to fit a byte budget (sharp)
functions/
authorizer.ts # Lambda: path-aware auth (OAuth pass-through, JWT + static)
vault-mcp/
@@ -116,26 +117,29 @@ src/
links.ts # Link grammar: parse, extract, resolve (wikilinks + md; notes + assets)
tasks.ts # Tasks-plugin task-line grammar + mutation (emoji + Dataview fields)
memory-entries.ts # Memory-entry grammar (dated bullets in About Me/ files)
+ canvas.ts # .canvas linearizer (JSON Canvas 1.0 → readable markdown)
plaintext.ts # Strip Obsidian/Markdown syntax → plain text
vault-operations/ # Vault content read/write/patch (filesystem I/O)
- vault-filesystem.ts # Read/write/list/delete .md files; list non-md assets; outline + section reads
+ vault-filesystem.ts # Read/write/list/delete .md files; read/list/stat non-md assets; outline + section reads
vault-patcher.ts # Surgical edits: heading-targeted patch + find-and-replace
note-mover.ts # Move/rename a note + rewrite every vault-wide link to it
memory-store.ts # About Me/ heading-aware read/append/delete
daily-notes.ts # Daily note config reader + path resolver
task-updater.ts # Task state mutations (status, priority, lane moves)
task-format-config.ts # Tasks-plugin format config reader (emoji vs Dataview)
+ asset-operations.ts # Asset read dispatch + browsing (image fit, canvas linearize/raw, extension filter, statted slice)
mcp-core/ # MCP protocol surface
mcp-router.ts # /mcp session routes + transport lifecycle
tool-definitions.ts # Tool orchestrator — TOOL_NAMES + conditional group registration
prompt-definitions.ts # Prompt orchestrator — PROMPT_NAMES + conditional group registration
tools/ # Tool group modules (one per data-layer domain)
- tool-helpers.ts # Shared ToolRegistrationContext type + safeHandler
+ tool-helpers.ts # Shared ToolRegistrationContext type + safeHandler/safeHandlerContent
vault-crud-tools.ts # 9 tools: read, write, patch, replace, delete, move
search-tools.ts # 11 tools: search, tags, properties, graph queries
task-tools.ts # 2 tools: list-tasks, update-task
memory-tools.ts # 5 tools: get/update/list/delete memory + memory recall
daily-note-tools.ts # 1 tool: get daily note
+ asset-tools.ts # 2 tools: read-asset, list-assets
prompts/ # Prompt group modules (one per prompt)
prompt-helpers.ts # Shared PromptRegistrationContext type + formatting helpers
vault-orientation-prompt.ts # 1 prompt: vault structure + health survey
@@ -163,10 +167,14 @@ The `vault-mcp/` tree is organized in dependency layers — parsers → I/O →
use-cases → protocol → wiring. A module's folder is decided by **what it depends
on**, not just its topic:
-- **`obsidian-markdown/`** — pure parsers/transforms over Obsidian-flavored
- Markdown (frontmatter, lines, headings, callouts, links). **No fs, no SQLite,
+- **`obsidian-markdown/`** — pure parsers/transforms over Obsidian's file
+ formats (frontmatter, lines, headings, callouts, links). **No fs, no SQLite,
no MCP**; they take strings/lines and return data or transformed strings, so
- they're trivially unit-testable. `lines.ts` is the single home of the
+ they're trivially unit-testable. The folder's contract is the dependency
+ profile, not the syntax family: `canvas.ts` parses JSON (JSON Canvas 1.0),
+ but its text nodes and its linearized output are markdown, and it's the same
+ pure leaf layer — Obsidian format parsers belong here regardless of whether
+ the format is markdown, JSON, or YAML. `lines.ts` is the single home of the
CommonMark §4.5 fence state machine (`advanceFence`) — every fence-aware walk
threads it, so they can't disagree about where a fence opens.
**Dual-format task mutations:** `tasks.ts` reads **and writes** both emoji
@@ -190,12 +198,20 @@ on**, not just its topic:
any file) isn't enough to demote something to `utils/` if it's load-bearing
vault-I/O policy.
- **`mcp-core/`** — the MCP protocol surface. `tool-definitions.ts` is the
- orchestrator that composes `TOOL_NAMES` from four domain group modules under
- `mcp-core/tools/` (vault-crud, search, memory, daily-note) and calls each
- register function — conditionally skipping memory tools when `MEMORY_ENABLED`
- is `false`. Each group module is self-contained: its own tool name constants,
- register function, and data-layer imports. Shared helpers (`safeHandler`,
- `formatNoteMetadata`, `ToolRegistrationContext` type) live in `tool-helpers.ts`.
+ orchestrator that composes `TOOL_NAMES` from the domain group modules under
+ `mcp-core/tools/` (vault-crud, search, memory, daily-note, task, asset) and
+ calls each register function — conditionally skipping memory tools when
+ `MEMORY_ENABLED` is `false`. Each group module is self-contained: its own tool
+ name constants, register function, and data-layer imports. Shared helpers
+ (`safeHandler`, `formatNoteMetadata`, `ToolRegistrationContext` type) live in
+ `tool-helpers.ts`.
+ **Tool handlers stay thin**: schema, wire mapping (snake_case ↔ camelCase),
+ one data-layer call, and content-block/JSON formatting. Multi-step
+ composition — filtering, counting, pagination, dispatching across parsers
+ and I/O — is a _use-case_ and belongs in `vault-operations/`
+ (`asset-reader.ts` and `asset-listing.ts` are the worked examples). The
+ smell: a handler importing a parser to orchestrate between two data-layer
+ calls; the fix is a use-case module, not a bigger handler.
`prompt-definitions.ts` is the orchestrator that composes `PROMPT_NAMES` from
three group modules under `mcp-core/prompts/` (vault-orientation, memory-review,
daily-review) — mirroring the `tools/` pattern. Shared helpers
@@ -213,6 +229,14 @@ Two rules keep this honest:
`mcp-core/` and the top-level wiring depend on everything. A _search_ module
importing a _parser_ should read as "uses the shared parser," never as reaching
sideways into `vault-operations/`.
+- **Group operations by shared dependency layer, not by topic.** A domain's
+ operations live together only when they share a layer: asset read + browse
+ are both filesystem work, so `asset-operations.ts` holds both. Task list
+ (a SQL query — lives with the queries in `search/`) and task update (a file
+ mutation — `task-updater.ts`) stay apart, and so do note search and note
+ mutations. A topic-symmetric "one module per domain" grouping that crosses
+ layers is the smell, not the goal — each file answers for one layer's view
+ of its domain.
- **Top level is wiring only.** Folders are domains; the only loose files at
`vault-mcp/` are the entry point (`server.ts`) and its `config.ts`.
@@ -240,20 +264,24 @@ goes in `obsidian-markdown/`, never `utils/`.
**Export style** depends on what kind of module it is:
-- **Service / data-layer modules** — those that wrap a cohesive set of operations
- over a resource (the vault, the index) — export a **single namespace object**
- so call sites self-document which module an operation belongs to:
- `vaultFs.readNote(…)`, `vaultPatcher.patchNote(…)`, `noteMover.moveNote(…)`.
- Stateful ones use a **factory-closure** returning that object
- (`createSearchIndex`, `createMemoryStore`), so prepared statements / caches
- live in the closure.
-- **Parser and small-helper modules** — the `obsidian-markdown/` parsers
- (`frontmatter`, `headings`, `callouts`, `lines`), `utils/`, and `daily-notes` —
- export **named functions**. The shape tracks whether a module is a _cohesive
- service surface_ (→ namespace) or just a loose set of functions (→ named),
- **not** whether it does I/O: the parsers are pure, while `daily-notes` does
- light I/O (reads and caches daily-note config), yet both use named exports
- because neither is a grouped service API.
+- **Operation / data-layer modules** — anything that performs vault or index
+ operations — export a **single namespace object** so call sites self-document
+ which module an operation belongs to: `vaultFs.readNote(…)`,
+ `vaultPatcher.patchNote(…)`, `noteMover.moveNote(…)`,
+ `assetOperations.readAssetContent(…)`.
+ **Function count is irrelevant** — `noteMover` is essentially a
+ single-operation module and still exports a namespace; "it only has one
+ function" is not the named-export test. Stateful ones use a
+ **factory-closure** returning that object (`createSearchIndex`,
+ `createMemoryStore`), so prepared statements / caches live in the closure.
+- **Parser, small-helper, and config-reader modules** — the
+ `obsidian-markdown/` parsers (`frontmatter`, `headings`, `callouts`,
+ `lines`), `utils/`, and the config readers (`daily-notes`,
+ `task-format-config`) — export **named functions**. The shape tracks whether
+ a module _performs operations_ (→ namespace) or _parses/reads
+ configuration_ (→ named), **not** whether it does I/O: the parsers are pure,
+ while the config readers do light I/O, yet both use named exports because
+ neither is an operation surface.
- **`links.ts` is the deliberate edge case** — a pure parser that nonetheless
exports a single `links` namespace, _not_ for the service-grouping reason above
but to wall off its `/g` grammar regexes (shared `lastIndex` footgun) behind
@@ -564,6 +592,12 @@ Two naming layers — MCP (JSON wire format) and TypeScript (internal):
(`src/utils/assert-path-has-extension.ts`), called in the data-layer function
each tool routes through (one rule, every layer). Folder, glob, and
memory-file (`file`) inputs are exempt.
+- **`vault_read_asset` is the deliberate inverse.** Its `path` names any
+ non-markdown file and **must not** end in `.md` — `vaultFs.readAsset`
+ rejects notes so the `.md` boundary stays a single rule with two sides
+ (notes → `vault_read_note`, everything else → `vault_read_asset`). Error
+ messages never name tools; the routing guidance lives in each tool's
+ description.
### Test conventions
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 0eeb33de..90cac7ff 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -281,9 +281,27 @@ Link queries use a `links` table populated during indexing:
2. Path relative to the linking note (path from current file, including upward `../`)
3. Basename (shortest-path-first for ambiguous basenames)
- **Non-markdown assets:** Targets that don't resolve to a note are checked against a `non_md_files` table (populated during rebuild, maintained by the file watcher). Both wikilinks and markdown-style links to `.canvas`, `.base`, images, PDFs, and other non-markdown assets resolve as `kind: "asset"` instead of being counted as broken.
-- **Outgoing links:** `vault_get_outgoing_links` returns a `kind` discriminator (`"note"` or `"asset"`) so clients can distinguish retrievable notes from non-retrievable asset references.
+- **Outgoing links:** `vault_get_outgoing_links` returns a `kind` discriminator (`"note"` or `"asset"`) plus each target's byte size (`bytes` — from the notes table for notes, from `non_md_files` for assets), so clients can route notes to `vault_read_note` and assets to `vault_read_asset` with size awareness.
- **Orphans:** `vault_find_orphans` excludes folders listed in `ORPHAN_EXCLUDE_FOLDERS` (default: `Daily Notes`, `Templates`, and the memory dir).
+### Assets
+
+| Tool | Input | Annotation |
+| ------------------- | ------------------------------ | ------------ |
+| `vault_read_asset` | `path` | readOnlyHint |
+| `vault_list_assets` | `folder?, extensions?, limit?` | readOnlyHint |
+
+`vault_read_asset` reads non-markdown vault files, dispatching on extension to the most useful representation per type:
+
+1. **Images** (`.png`/`.jpg`/`.jpeg`/`.gif`/`.webp`) return an MCP `image` content block plus a one-line metadata text block. 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.
+3. **Text formats** (`.svg`/`.json`/`.txt`/`.csv`/`.xml`/`.log`/`.base`) pass through verbatim as text, capped at a fixed 100 KiB output size (explicit error over silent truncation).
+4. **PDFs** return a structured not-yet-supported error carrying the file's existence and size (text extraction is a planned follow-up); unknown types return an error naming the readable set.
+
+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 guard as notes, rejects `.md` paths (notes belong to `vault_read_note`), and enforces a stat-before-read size cap (`MAX_ASSET_BYTES`, default 50 MiB).
+
+`vault_list_assets` 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. Assets are readable and browsable but not yet searchable — content indexing is a possible future tier.
+
### Tasks (R9)
| Tool | Input | Annotation |
diff --git a/DOCKERHUB.md b/DOCKERHUB.md
index d0da463a..e10730b1 100644
--- a/DOCKERHUB.md
+++ b/DOCKERHUB.md
@@ -48,6 +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. 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
+- **[Assets](https://github.com/aliasunder/vault-cortex#assets)** — read the vault's non-markdown files too: images arrive as actual images (shrunk to fit when needed), canvases as readable outlines, data files 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
@@ -58,6 +59,17 @@
See the [full Quick Start guide](https://github.com/aliasunder/vault-cortex#quick-start) for local setup (2 minutes with Docker), remote deployment with Obsidian Sync, and MCP client configuration.
+## Assets
+
+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 assets as part of the vault rather than clutter around it — linked, sized, and readable, each in the form an agent can actually 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. The exact JSON source is one flag away when full fidelity matters
+- **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written
+- **Browse** — list any folder's assets with per-extension counts and file sizes; assets a note links to report their size in the link graph too
+
+See [ARCHITECTURE.md → Assets](https://github.com/aliasunder/vault-cortex/blob/main/ARCHITECTURE.md#assets) for the image pipeline and dispatch model.
+
## Tools
| Category | Tool | Description |
@@ -89,6 +101,8 @@ See the [full Quick Start guide](https://github.com/aliasunder/vault-cortex#quic
| **Links** | `vault_get_backlinks` | Notes linking to a given path |
| | `vault_get_outgoing_links` | Links from a given note |
| | `vault_find_orphans` | Notes with no incoming links |
+| **Assets** | `vault_read_asset` | Read a non-markdown file — images delivered as images, canvases as readable outlines |
+| | `vault_list_assets` | Browse the vault's non-markdown files with sizes and per-extension counts |
| **Daily Notes** | `vault_get_daily_note` | Today's (or any date's) daily note |
## Prompts
diff --git a/README.md b/README.md
index ed5a8650..75d96fd3 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
**Vault Cortex** is a standalone MCP server that gives any AI agent **hybrid search, task management, structured memory, and read/write access** to your [Obsidian](https://obsidian.md) vault. No plugins, no running Obsidian, no separate bridge. One Docker container, your vault folder, a full tool suite + guided prompts. Deploy on a VPS with Obsidian Sync and the same vault is accessible from your phone, claude.ai, or any remote MCP client, secured with OAuth 2.1.
-**Contents** — [What you get](#what-you-get) · [Quick Start](#quick-start) · [How It Works](#how-it-works) · [Hybrid Search](#hybrid-search) · [Memory](#memory) · [Tasks](#tasks) · [Tools](#tools) · [Prompts](#prompts) · [Config](#configuration) · [Data Integrity](#data-integrity) · [Auth](#authentication) · [Deployment](#deployment-options)
+**Contents** — [What you get](#what-you-get) · [Quick Start](#quick-start) · [How It Works](#how-it-works) · [Hybrid Search](#hybrid-search) · [Memory](#memory) · [Tasks](#tasks) · [Assets](#assets) · [Tools](#tools) · [Prompts](#prompts) · [Config](#configuration) · [Data Integrity](#data-integrity) · [Auth](#authentication) · [Deployment](#deployment-options)
## What you get
@@ -42,6 +42,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. 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
+- **[Assets](#assets)** — read the vault's non-markdown files too: images arrive as actual images (shrunk to fit when needed), canvases as readable outlines, data files 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
@@ -212,6 +213,17 @@ The task layer handles this so agents don't have to:
See [ARCHITECTURE.md → Tasks](./ARCHITECTURE.md#tasks-r9) for the indexing model, date cascade sorting, and Kanban lane detection.
+## Assets
+
+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 assets as part of the vault rather than clutter around it — linked, sized, and readable, each in the form an agent can actually 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. The exact JSON source is one flag away when full fidelity matters
+- **Text and data files** — SVG, JSON, CSV, logs, and [Bases](https://help.obsidian.md/bases) files return exactly as written
+- **Browse** — list any folder's assets with per-extension counts and file sizes; assets a note links to report their size in the link graph too
+
+See [ARCHITECTURE.md → Assets](./ARCHITECTURE.md#assets) for the image pipeline and dispatch model.
+
## Tools
| Category | Tool | Description |
@@ -243,6 +255,8 @@ See [ARCHITECTURE.md → Tasks](./ARCHITECTURE.md#tasks-r9) for the indexing mod
| **Links** | `vault_get_backlinks` | Notes linking to a given path |
| | `vault_get_outgoing_links` | Links from a given note |
| | `vault_find_orphans` | Notes with no incoming links |
+| **Assets** | `vault_read_asset` | Read a non-markdown file — images delivered as images, canvases as readable outlines |
+| | `vault_list_assets` | Browse the vault's non-markdown files with sizes and per-extension counts |
| **Daily Notes** | `vault_get_daily_note` | Today's (or any date's) daily note |
## Prompts
diff --git a/assets/social-preview.png b/assets/social-preview.png
index 83eae351..18a7a315 100644
Binary files a/assets/social-preview.png and b/assets/social-preview.png differ
diff --git a/assets/social-preview.svg b/assets/social-preview.svg
index 050e6d60..98150049 100644
--- a/assets/social-preview.svg
+++ b/assets/social-preview.svg
@@ -59,5 +59,5 @@
hybrid search · notes · memory · tasks · link graph · OAuth 2.1
+ font-size="19" fill="#58a6ff">hybrid search · notes · memory · tasks · assets · link graph · OAuth 2.1
diff --git a/cli/README.md b/cli/README.md
index 00f5de58..139aa4c8 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -9,7 +9,8 @@ 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 — see the
+access to your Obsidian vault — plus read-only access to its images,
+canvases, 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 02896943..bf52788f 100644
--- a/cli/src/env.ts
+++ b/cli/src/env.ts
@@ -34,6 +34,16 @@ const LOCAL_OPTIONAL_BLOCK = `# Optional ─────────────
# Override if you expose the server on a different URL (e.g. via a reverse proxy).
PUBLIC_URL=http://localhost:8000
+# Largest asset file vault_read_asset will read, in bytes (default: 52428800 = 50 MiB).
+# Reading a larger file returns an error instead of content.
+MAX_ASSET_BYTES=52428800
+
+# Byte budget for images returned by vault_read_asset, in binary bytes before
+# base64 encoding (default: 49152 = 48 KiB, sized for Claude Code's response cap).
+# Images exceeding the budget are downscaled/recompressed to fit. Raise it for clients
+# that accept larger tool responses.
+MAX_IMAGE_OUTPUT_BYTES=49152
+
# Your IANA timezone — affects daily note resolution and memory timestamps.
# TZ=America/New_York
@@ -128,6 +138,16 @@ RERANK_MODE=blended
# the Docker Desktop/WSL2 bridge.
WINDOWS_MODE=false
+# Largest asset file vault_read_asset will read, in bytes (default: 52428800 = 50 MiB).
+# Reading a larger file returns an error instead of content.
+MAX_ASSET_BYTES=52428800
+
+# Byte budget for images returned by vault_read_asset, in binary bytes before
+# base64 encoding (default: 49152 = 48 KiB, sized for Claude Code's response cap).
+# Images exceeding the budget are downscaled/recompressed to fit. Raise it for clients
+# that accept larger tool responses.
+MAX_IMAGE_OUTPUT_BYTES=49152
+
# Enable or disable the memory layer (default: true).
# Set to false to hide memory tools and skip About Me/ creation.
MEMORY_ENABLED=true
diff --git a/deploy/local/.env.example b/deploy/local/.env.example
index 4a5765d5..500fe76c 100644
--- a/deploy/local/.env.example
+++ b/deploy/local/.env.example
@@ -17,6 +17,16 @@ VAULT_PATH=
# Override if you expose the server on a different URL (e.g. via a reverse proxy).
PUBLIC_URL=http://localhost:8000
+# Largest asset file vault_read_asset will read, in bytes (default: 52428800 = 50 MiB).
+# Reading a larger file returns an error instead of content.
+MAX_ASSET_BYTES=52428800
+
+# Byte budget for images returned by vault_read_asset, in binary bytes before
+# base64 encoding (default: 49152 = 48 KiB, sized for Claude Code's response cap).
+# Images exceeding the budget are downscaled/recompressed to fit. Raise it for clients
+# that accept larger tool responses.
+MAX_IMAGE_OUTPUT_BYTES=49152
+
# Your IANA timezone — affects daily note resolution and memory timestamps.
# TZ=America/New_York
diff --git a/deploy/local/docker-compose.yml b/deploy/local/docker-compose.yml
index 4572a1f1..bcfb9d95 100644
--- a/deploy/local/docker-compose.yml
+++ b/deploy/local/docker-compose.yml
@@ -36,6 +36,8 @@ services:
# Windows: set WINDOWS_MODE=true in .env when your vault is on a C: drive
# (polling watcher + rename-based moves across the Docker Desktop/WSL2 bridge).
WINDOWS_MODE: ${WINDOWS_MODE:-false}
+ MAX_ASSET_BYTES: ${MAX_ASSET_BYTES:-52428800}
+ MAX_IMAGE_OUTPUT_BYTES: ${MAX_IMAGE_OUTPUT_BYTES:-49152}
# Optional overrides. When unset, the server applies smart defaults
# ( below is the resolved MEMORY_DIR value, default "About Me"):
# PROTECTED_PATHS default: ", Daily Notes" (blocked from vault_delete_note)
diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example
index 008afb43..a16c75f5 100644
--- a/deploy/remote/.env.example
+++ b/deploy/remote/.env.example
@@ -49,6 +49,16 @@ RERANK_MODE=blended
# the Docker Desktop/WSL2 bridge.
WINDOWS_MODE=false
+# Largest asset file vault_read_asset will read, in bytes (default: 52428800 = 50 MiB).
+# Reading a larger file returns an error instead of content.
+MAX_ASSET_BYTES=52428800
+
+# Byte budget for images returned by vault_read_asset, in binary bytes before
+# base64 encoding (default: 49152 = 48 KiB, sized for Claude Code's response cap).
+# Images exceeding the budget are downscaled/recompressed to fit. Raise it for clients
+# that accept larger tool responses.
+MAX_IMAGE_OUTPUT_BYTES=49152
+
# Enable or disable the memory layer (default: true).
# Set to false to hide memory tools and skip About Me/ creation.
MEMORY_ENABLED=true
diff --git a/deploy/remote/docker-compose.yml b/deploy/remote/docker-compose.yml
index c11183d2..08a5fadb 100644
--- a/deploy/remote/docker-compose.yml
+++ b/deploy/remote/docker-compose.yml
@@ -47,6 +47,8 @@ services:
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30}
TZ: ${TZ:-UTC}
WINDOWS_MODE: ${WINDOWS_MODE:-false}
+ MAX_ASSET_BYTES: ${MAX_ASSET_BYTES:-52428800}
+ MAX_IMAGE_OUTPUT_BYTES: ${MAX_IMAGE_OUTPUT_BYTES:-49152}
# Optional overrides. When unset, the server applies smart defaults
# ( below is the resolved MEMORY_DIR value, default "About Me"):
# PROTECTED_PATHS default: ", Daily Notes" (blocked from vault_delete_note)
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index c2904639..8821df8a 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -32,6 +32,8 @@ services:
# Windows: set WINDOWS_MODE=true when developing against a vault on a C: drive
# (polling watcher + rename-based moves across the Docker Desktop/WSL2 bridge).
WINDOWS_MODE: ${WINDOWS_MODE:-false}
+ MAX_ASSET_BYTES: ${MAX_ASSET_BYTES:-52428800}
+ MAX_IMAGE_OUTPUT_BYTES: ${MAX_IMAGE_OUTPUT_BYTES:-49152}
# Left empty = the server applies smart defaults
# ( below is the resolved MEMORY_DIR value, default "About Me"):
# PROTECTED_PATHS default: ", Daily Notes"
diff --git a/docker-compose.yml b/docker-compose.yml
index 4ab65284..f65e1dc2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -45,6 +45,8 @@ services:
RERANK_MODE: ${RERANK_MODE:-blended}
MEMORY_ENABLED: ${MEMORY_ENABLED:-true}
WINDOWS_MODE: ${WINDOWS_MODE:-false}
+ MAX_ASSET_BYTES: ${MAX_ASSET_BYTES:-52428800}
+ MAX_IMAGE_OUTPUT_BYTES: ${MAX_IMAGE_OUTPUT_BYTES:-49152}
MEMORY_DIR: ${MEMORY_DIR:-About Me}
# Left empty = the server applies smart defaults
# ( below is the resolved MEMORY_DIR value, default "About Me"):
diff --git a/package-lock.json b/package-lock.json
index 115d1f95..28cf0fcd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"gray-matter": "4.0.3",
"luxon": "3.7.2",
"picomatch": "4.0.5",
+ "sharp": "0.35.3",
"sqlite-vec": "0.1.9",
"yaml": "2.9.0",
"zod": "4.4.3"
@@ -827,7 +828,6 @@
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
- "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1455,6 +1455,13 @@
"sharp": "^0.34.5"
}
},
+ "node_modules/@huggingface/transformers/node_modules/sharp": {
+ "name": "empty-npm-package",
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/empty-npm-package/-/empty-npm-package-1.0.0.tgz",
+ "integrity": "sha512-q4Mq/+XO7UNDdMiPpR/LIBIW1Zl4V0Z6UT9aKGqIAnBCtCb3lvZJM1KbDbdzdC8fKflwflModfjR29Nt0EpcwA==",
+ "license": "ISC"
+ },
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1521,6 +1528,554 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -6357,9 +6912,9 @@
}
},
"node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -6441,11 +6996,53 @@
"license": "ISC"
},
"node_modules/sharp": {
- "name": "empty-npm-package",
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/empty-npm-package/-/empty-npm-package-1.0.0.tgz",
- "integrity": "sha512-q4Mq/+XO7UNDdMiPpR/LIBIW1Zl4V0Z6UT9aKGqIAnBCtCb3lvZJM1KbDbdzdC8fKflwflModfjR29Nt0EpcwA==",
- "license": "ISC"
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
},
"node_modules/shebang-command": {
"version": "2.0.0",
@@ -7075,7 +7672,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
+ "devOptional": true,
"license": "0BSD"
},
"node_modules/tsx": {
diff --git a/package.json b/package.json
index c2be9af5..1f68add4 100644
--- a/package.json
+++ b/package.json
@@ -71,6 +71,7 @@
"gray-matter": "4.0.3",
"luxon": "3.7.2",
"picomatch": "4.0.5",
+ "sharp": "0.35.3",
"sqlite-vec": "0.1.9",
"yaml": "2.9.0",
"zod": "4.4.3"
diff --git a/server.json b/server.json
index f838d3b4..c2e3fcc7 100644
--- a/server.json
+++ b/server.json
@@ -86,6 +86,11 @@
"default": "blended",
"choices": ["blended", "none"]
},
+ {
+ "name": "WINDOWS_MODE",
+ "description": "Windows bind-mount mode: enables filesystem polling for the file watcher and rename-based moves across the Docker Desktop/WSL2 bridge. Set to true when the vault lives on a Windows drive.",
+ "default": "false"
+ },
{
"name": "MEMORY_ENABLED",
"description": "Enable or disable the structured memory layer. When false, memory tools are hidden, bootstrap is skipped, and server metadata omits memory references.",
@@ -130,6 +135,18 @@
"name": "SERVICE_DOCUMENTATION_URL",
"description": "Override the OAuth service documentation URL exposed via discovery metadata.",
"default": "https://github.com/aliasunder/vault-cortex"
+ },
+ {
+ "name": "MAX_ASSET_BYTES",
+ "description": "Largest asset file vault_read_asset will read, in bytes. Reading a larger file returns an error instead of content.",
+ "default": "52428800",
+ "format": "number"
+ },
+ {
+ "name": "MAX_IMAGE_OUTPUT_BYTES",
+ "description": "Byte budget for images returned by vault_read_asset, in binary bytes before base64 encoding. Images exceeding the budget are downscaled/recompressed server-side to fit; raise for clients that accept larger tool responses.",
+ "default": "49152",
+ "format": "number"
}
]
}
diff --git a/src/utils/__tests__/fit-image-to-byte-budget.test.ts b/src/utils/__tests__/fit-image-to-byte-budget.test.ts
new file mode 100644
index 00000000..c36f5dce
--- /dev/null
+++ b/src/utils/__tests__/fit-image-to-byte-budget.test.ts
@@ -0,0 +1,122 @@
+import { describe, it, expect } from "vitest"
+import sharp from "sharp"
+import { fitImageToByteBudget } from "../fit-image-to-byte-budget.js"
+
+/** Gaussian-noise fixture — noise resists compression, so size assertions
+ * exercise the real descent logic instead of trivially fitting. */
+const noiseImage = (params: {
+ width: number
+ height: number
+ alpha?: boolean
+}): Promise => {
+ const channels = params.alpha ? 4 : 3
+ return sharp({
+ create: {
+ width: params.width,
+ height: params.height,
+ channels,
+ background: { r: 128, g: 128, b: 128, alpha: 1 },
+ noise: { type: "gaussian", mean: 128, sigma: 30 },
+ },
+ })
+ .png()
+ .toBuffer()
+}
+
+describe("fitImageToByteBudget", () => {
+ it("passes a small supported image through untouched", async () => {
+ const original = await sharp({
+ create: {
+ width: 100,
+ height: 80,
+ channels: 3,
+ background: { r: 255, g: 0, b: 255 },
+ },
+ })
+ .png()
+ .toBuffer()
+ const fitted = await fitImageToByteBudget({
+ buffer: original,
+ budgetBytes: 49152,
+ })
+ expect(fitted.data.equals(original)).toBe(true)
+ expect(fitted).toMatchObject({
+ mimeType: "image/png",
+ width: 100,
+ height: 80,
+ originalWidth: 100,
+ originalHeight: 80,
+ recompressed: false,
+ })
+ })
+
+ it("downscales an oversized opaque image to JPEG within the budget", async () => {
+ const original = await noiseImage({ width: 2400, height: 1600 })
+ const budgetBytes = 49152
+ const fitted = await fitImageToByteBudget({ buffer: original, budgetBytes })
+ expect(fitted.data.length).toBeLessThanOrEqual(budgetBytes)
+ expect(fitted.mimeType).toBe("image/jpeg")
+ expect(fitted.recompressed).toBe(true)
+ expect(Math.max(fitted.width, fitted.height)).toBeLessThanOrEqual(1568)
+ expect(fitted.originalWidth).toBe(2400)
+ expect(fitted.originalHeight).toBe(1600)
+ })
+
+ it("recompresses an alpha image to WebP, not JPEG", async () => {
+ const original = await noiseImage({
+ width: 2000,
+ height: 2000,
+ alpha: true,
+ })
+ const budgetBytes = 49152
+ const fitted = await fitImageToByteBudget({ buffer: original, budgetBytes })
+ expect(fitted.mimeType).toBe("image/webp")
+ expect(fitted.data.length).toBeLessThanOrEqual(budgetBytes)
+ expect(fitted.recompressed).toBe(true)
+ })
+
+ it("shrinks dimensions below 1568 when the quality ladder alone cannot fit", async () => {
+ const original = await noiseImage({ width: 3000, height: 3000 })
+ // Small enough that no 1568px JPEG of gaussian noise can fit.
+ const budgetBytes = 8192
+ const fitted = await fitImageToByteBudget({ buffer: original, budgetBytes })
+ expect(fitted.data.length).toBeLessThanOrEqual(budgetBytes)
+ expect(Math.max(fitted.width, fitted.height)).toBeLessThan(1568)
+ })
+
+ it("applies EXIF orientation before resizing", async () => {
+ // Landscape pixels + EXIF orientation 6 (rotate 90° CW) = portrait image.
+ const rotatedSource = await sharp({
+ create: {
+ width: 2000,
+ height: 1000,
+ channels: 3,
+ background: { r: 10, g: 200, b: 50 },
+ },
+ })
+ .jpeg()
+ .withMetadata({ orientation: 6 })
+ .toBuffer()
+ const fitted = await fitImageToByteBudget({
+ buffer: rotatedSource,
+ budgetBytes: 49152,
+ })
+ expect(fitted.height).toBeGreaterThan(fitted.width)
+ })
+
+ it("throws when no attempt can fit the budget", async () => {
+ const original = await noiseImage({ width: 3000, height: 3000 })
+ await expect(
+ fitImageToByteBudget({ buffer: original, budgetBytes: 10 }),
+ ).rejects.toThrow(/^image cannot be fitted into 10 bytes/)
+ })
+
+ it("throws a decode error for a non-image buffer", async () => {
+ await expect(
+ fitImageToByteBudget({
+ buffer: Buffer.from("not an image at all"),
+ budgetBytes: 49152,
+ }),
+ ).rejects.toThrow("Input buffer contains unsupported image format")
+ })
+})
diff --git a/src/utils/__tests__/fs.test.ts b/src/utils/__tests__/fs.test.ts
index 0498e73c..2585722d 100644
--- a/src/utils/__tests__/fs.test.ts
+++ b/src/utils/__tests__/fs.test.ts
@@ -2,7 +2,7 @@ import { describe, it, expect, onTestFinished } from "vitest"
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"
import { join } from "node:path"
import { tmpdir } from "node:os"
-import { readFileOrNull, readdirOrNull, fileExists } from "../fs.js"
+import { readFileOrNull, readdirOrNull, fileExists, statOrNull } from "../fs.js"
const makeTempDir = async (): Promise => {
const dir = await mkdtemp(join(tmpdir(), "utils-fs-test-"))
@@ -49,6 +49,30 @@ describe("readdirOrNull", () => {
})
})
+describe("statOrNull", () => {
+ it("returns Stats when the path exists", async () => {
+ const dir = await makeTempDir()
+ const path = join(dir, "file.txt")
+ await writeFile(path, "12345", "utf8")
+ const stats = await statOrNull(path)
+ expect(stats?.isFile()).toBe(true)
+ expect(stats?.size).toBe(5)
+ })
+
+ it("returns null when the path does not exist", async () => {
+ const dir = await makeTempDir()
+ expect(await statOrNull(join(dir, "ghost.txt"))).toBeNull()
+ })
+
+ it("rethrows a non-ENOENT error rather than swallowing it", async () => {
+ // ENOTDIR: stat a path through a file as if it were a directory
+ const dir = await makeTempDir()
+ const filePath = join(dir, "file.txt")
+ await writeFile(filePath, "x", "utf8")
+ await expect(statOrNull(join(filePath, "child"))).rejects.toThrow(/ENOTDIR/)
+ })
+})
+
describe("fileExists", () => {
it("returns true when the path exists", async () => {
const dir = await makeTempDir()
diff --git a/src/utils/fit-image-to-byte-budget.ts b/src/utils/fit-image-to-byte-budget.ts
new file mode 100644
index 00000000..01215688
--- /dev/null
+++ b/src/utils/fit-image-to-byte-budget.ts
@@ -0,0 +1,179 @@
+import sharp from "sharp"
+import type { OutputInfo } from "sharp"
+
+/**
+ * Fits an image into a byte budget by downscaling and recompressing —
+ * deterministic and provably terminating. Model-facing image transports cap
+ * response sizes well below typical photo sizes, so oversized images must be
+ * shrunk server-side; this module owns that policy for any caller.
+ *
+ * Strategy, in order:
+ * 1. Pass through untouched when the original already fits the budget, needs
+ * no downscale, and is in a model-supported format — no quality loss.
+ * 2. Auto-orient (EXIF), resize the long edge to ≤1568px (models downscale
+ * beyond that anyway), and walk a fixed quality ladder — JPEG for opaque
+ * images, WebP for images with alpha (PNG has no quality knob, so it
+ * cannot hit a byte budget reliably).
+ * 3. If the ladder floor still exceeds the budget, shrink dimensions by
+ * sqrt(budget/actual) per step (clamped so each step is a real reduction)
+ * at mid-ladder quality, down to a 64px floor.
+ *
+ * Throws when the attempt cap is reached without fitting — callers surface
+ * that as a structured error rather than silently sending an oversized image.
+ * Sharp's default limitInputPixels (~268 megapixels) stays active as the
+ * decompression-bomb guard; `failOn: "none"` tolerates slightly-corrupt files.
+ */
+
+/** Models downscale images beyond ~1568px on the long edge anyway, so pixels
+ * past this are bytes spent on detail the model never sees. */
+const MAX_LONG_EDGE_PX = 1568
+
+/** Dimension floor for the descent — below this an image stops being legible,
+ * so the fit gives up (throws) rather than delivering unreadable thumbnails. */
+const MIN_LONG_EDGE_PX = 64
+
+/** Fixed quality descent, tried in order at full dimensions: 75 is near
+ * visually lossless, 30 the legibility floor. A fixed ladder (vs adaptive
+ * search) keeps output deterministic for identical inputs. */
+const QUALITY_LADDER = [75, 60, 45, 30]
+
+/** Quality used once dimension-shrinking takes over from the ladder — the
+ * ladder's midpoint, since dimensions are now doing the size work and the
+ * floor qualities would degrade legibility for little gain. */
+const MID_LADDER_QUALITY = 45
+
+/** Hard bound on total encodes so termination is provable: the full ladder
+ * plus a handful of dimension-shrink attempts, after which the image is
+ * reported unfittable. */
+const MAX_ENCODE_ATTEMPTS = 8
+
+/** Formats the Claude API accepts as image input; anything else must be
+ * re-encoded even when it fits the budget. */
+const MODEL_SUPPORTED_FORMATS = new Map([
+ ["jpeg", "image/jpeg"],
+ ["png", "image/png"],
+ ["gif", "image/gif"],
+ ["webp", "image/webp"],
+])
+
+export type FittedImage = Readonly<{
+ data: Buffer
+ mimeType: string
+ width: number
+ height: number
+ originalWidth: number
+ originalHeight: number
+ /** False when the original bytes passed through untouched. */
+ recompressed: boolean
+}>
+
+/** One resize+encode pass; the encoder is WebP when alpha must survive
+ * (JPEG would flatten it), JPEG otherwise. */
+const encodeAttempt = async (params: {
+ buffer: Buffer
+ longEdgePx: number
+ quality: number
+ keepAlpha: boolean
+}): Promise<{ data: Buffer; info: OutputInfo }> => {
+ const resized = sharp(params.buffer, { failOn: "none" }).autoOrient().resize({
+ width: params.longEdgePx,
+ height: params.longEdgePx,
+ fit: "inside",
+ withoutEnlargement: true,
+ })
+ const encoded = params.keepAlpha
+ ? resized.webp({ quality: params.quality })
+ : resized.jpeg({ quality: params.quality, mozjpeg: true })
+ return encoded.toBuffer({ resolveWithObject: true })
+}
+
+/**
+ * Downscales/recompresses `buffer` until its encoded size is ≤ `budgetBytes`.
+ * Returns the fitted image with its final and original dimensions, or throws
+ * when the image cannot be fitted within the attempt cap.
+ */
+export const fitImageToByteBudget = async (params: {
+ buffer: Buffer
+ budgetBytes: number
+}): Promise => {
+ const metadata = await sharp(params.buffer, { failOn: "none" }).metadata()
+ const { width, height, format } = metadata
+ if (!width || !height || !format) {
+ throw new Error("could not decode image (no dimensions or format)")
+ }
+
+ const longEdge = Math.max(width, height)
+ const passthroughMime = MODEL_SUPPORTED_FORMATS.get(format)
+ const fitsAsIs =
+ params.buffer.length <= params.budgetBytes && longEdge <= MAX_LONG_EDGE_PX
+ if (fitsAsIs && passthroughMime) {
+ return {
+ data: params.buffer,
+ mimeType: passthroughMime,
+ width,
+ height,
+ originalWidth: width,
+ originalHeight: height,
+ recompressed: false,
+ }
+ }
+
+ const keepAlpha = Boolean(metadata.hasAlpha)
+ // Mutable descent state: each attempt either succeeds (returns) or tightens
+ // quality/dimensions for the next — inherently sequential.
+ let longEdgePx = Math.min(longEdge, MAX_LONG_EDGE_PX)
+ let attemptCount = 0
+ let qualityLadderIndex = 0
+ let lastEncodedBytes = params.buffer.length
+
+ while (attemptCount < MAX_ENCODE_ATTEMPTS) {
+ // The quality this attempt encodes at: the next ladder rung while the
+ // ladder descends, mid-ladder once dimension-shrinking takes over.
+ const attemptQuality =
+ qualityLadderIndex < QUALITY_LADDER.length
+ ? QUALITY_LADDER[qualityLadderIndex]
+ : MID_LADDER_QUALITY
+ if (!attemptQuality) break
+ const { data, info } = await encodeAttempt({
+ buffer: params.buffer,
+ longEdgePx,
+ quality: attemptQuality,
+ keepAlpha,
+ })
+ attemptCount += 1
+ lastEncodedBytes = info.size
+ if (info.size <= params.budgetBytes) {
+ return {
+ data,
+ mimeType: keepAlpha ? "image/webp" : "image/jpeg",
+ width: info.width,
+ height: info.height,
+ originalWidth: width,
+ originalHeight: height,
+ recompressed: true,
+ }
+ }
+ if (qualityLadderIndex < QUALITY_LADDER.length - 1) {
+ qualityLadderIndex += 1
+ continue
+ }
+ // Ladder floor still over budget — shrink dimensions. sqrt because encoded
+ // size scales roughly with pixel area; the 0.7 clamp guarantees each step
+ // is a real reduction even when the overshoot is marginal. Descent clamps
+ // to the 64px floor and encodes there before giving up — breaking only
+ // when no further reduction is possible.
+ qualityLadderIndex = QUALITY_LADDER.length
+ const areaScale = Math.sqrt(params.budgetBytes / lastEncodedBytes)
+ const nextLongEdgePx = Math.max(
+ MIN_LONG_EDGE_PX,
+ Math.floor(longEdgePx * Math.min(areaScale, 0.7)),
+ )
+ if (nextLongEdgePx >= longEdgePx) break
+ longEdgePx = nextLongEdgePx
+ }
+
+ throw new Error(
+ `image cannot be fitted into ${params.budgetBytes} bytes ` +
+ `(smallest attempt was ${lastEncodedBytes} bytes after ${attemptCount} attempts)`,
+ )
+}
diff --git a/src/utils/fs.ts b/src/utils/fs.ts
index 8e03fb48..00154525 100644
--- a/src/utils/fs.ts
+++ b/src/utils/fs.ts
@@ -1,5 +1,5 @@
import { readFile, readdir, stat } from "node:fs/promises"
-import type { Dirent } from "node:fs"
+import type { Dirent, Stats } from "node:fs"
import { isErrnoException } from "./is-errno-exception.js"
/** Reads a UTF-8 file, returning null instead of throwing when it does not exist
@@ -13,6 +13,17 @@ export const readFileOrNull = async (path: string): Promise => {
}
}
+/** Stats a path, returning null instead of throwing when nothing exists there
+ * (ENOENT). Any other error propagates. */
+export const statOrNull = async (path: string): Promise => {
+ try {
+ return await stat(path)
+ } catch (error) {
+ if (isErrnoException(error, "ENOENT")) return null
+ throw error
+ }
+}
+
/** Recursively reads a directory's entries (with file types), returning null
* instead of throwing when the directory does not exist (ENOENT). Any other
* error propagates. */
diff --git a/src/vault-mcp/__tests__/config.test.ts b/src/vault-mcp/__tests__/config.test.ts
index f46c1a22..3a4968fd 100644
--- a/src/vault-mcp/__tests__/config.test.ts
+++ b/src/vault-mcp/__tests__/config.test.ts
@@ -300,4 +300,52 @@ describe("loadConfig", () => {
expect(config.protectedPaths).toEqual(["About Me", "Daily Notes"])
})
})
+
+ describe("MAX_ASSET_BYTES", () => {
+ it("defaults to 50 MiB (52428800) when unset", () => {
+ const config = loadConfig(EMPTY_ENV)
+ expect(config.maxAssetBytes).toBe(52_428_800)
+ })
+
+ it("accepts a custom positive integer", () => {
+ const config = loadConfig({ MAX_ASSET_BYTES: "10485760" })
+ expect(config.maxAssetBytes).toBe(10_485_760)
+ })
+
+ it("rejects a non-integer value", () => {
+ expect(() => loadConfig({ MAX_ASSET_BYTES: "abc" })).toThrow(
+ /MAX_ASSET_BYTES/,
+ )
+ })
+
+ it.each(["0", "-1", "1.5"])("rejects non-positive-integer %s", (value) => {
+ expect(() => loadConfig({ MAX_ASSET_BYTES: value })).toThrow(
+ /MAX_ASSET_BYTES/,
+ )
+ })
+ })
+
+ describe("MAX_IMAGE_OUTPUT_BYTES", () => {
+ it("defaults to 48 KiB (49152) when unset", () => {
+ const config = loadConfig(EMPTY_ENV)
+ expect(config.maxImageOutputBytes).toBe(49_152)
+ })
+
+ it("accepts a custom positive integer", () => {
+ const config = loadConfig({ MAX_IMAGE_OUTPUT_BYTES: "65536" })
+ expect(config.maxImageOutputBytes).toBe(65_536)
+ })
+
+ it("rejects a non-integer value", () => {
+ expect(() => loadConfig({ MAX_IMAGE_OUTPUT_BYTES: "nope" })).toThrow(
+ /MAX_IMAGE_OUTPUT_BYTES/,
+ )
+ })
+
+ it.each(["0", "-1", "1.5"])("rejects non-positive-integer %s", (value) => {
+ expect(() => loadConfig({ MAX_IMAGE_OUTPUT_BYTES: value })).toThrow(
+ /MAX_IMAGE_OUTPUT_BYTES/,
+ )
+ })
+ })
})
diff --git a/src/vault-mcp/config.ts b/src/vault-mcp/config.ts
index c114dde3..1e97412a 100644
--- a/src/vault-mcp/config.ts
+++ b/src/vault-mcp/config.ts
@@ -57,6 +57,14 @@ export type VaultConfig = Readonly<{
* rename-based exclusive write for moves (hard links aren't supported there).
* Set via WINDOWS_MODE; safe to leave on for any Windows setup. */
windowsBindMount: boolean
+ /** Per-read byte cap for vault_read_asset — files larger than this are
+ * rejected before reading (memory guard). Set via MAX_ASSET_BYTES. */
+ maxAssetBytes: number
+ /** Byte budget for image output after downscale/recompress, in binary bytes
+ * BEFORE base64 encoding. The default fits Claude Code's MCP output token
+ * cap (base64 expands ~4/3, then tokenizes at roughly 3 chars/token).
+ * Set via MAX_IMAGE_OUTPUT_BYTES; raise for clients with looser caps. */
+ maxImageOutputBytes: number
}>
// ── Loader ─────────────────────────────────────────────────────
@@ -111,6 +119,32 @@ export const loadConfig = (
.default("false")
.asBool()
+ // env-var's asIntPositive admits 0, but a zero byte cap would make every
+ // asset read fail at runtime — reject it at startup instead.
+ const requireNonZeroBytes = (name: string, value: number): number => {
+ if (value === 0) {
+ throw new Error(`env-var: "${name}" must be greater than 0`)
+ }
+ return value
+ }
+
+ // 50 MiB — matches the most permissive prior art for MCP attachment reads.
+ const maxAssetBytes = requireNonZeroBytes(
+ "MAX_ASSET_BYTES",
+ envVar.from(env).get("MAX_ASSET_BYTES").default("52428800").asIntPositive(),
+ )
+
+ // 48 KiB binary ≈ 64 KiB base64 ≈ ~21k tokens — under Claude Code's 25k-token
+ // MCP output cap with headroom for the metadata text block.
+ const maxImageOutputBytes = requireNonZeroBytes(
+ "MAX_IMAGE_OUTPUT_BYTES",
+ envVar
+ .from(env)
+ .get("MAX_IMAGE_OUTPUT_BYTES")
+ .default("49152")
+ .asIntPositive(),
+ )
+
return Object.freeze({
memoryEnabled,
memoryDir,
@@ -120,5 +154,7 @@ export const loadConfig = (
embeddingEnabled,
rerankMode,
windowsBindMount,
+ maxAssetBytes,
+ maxImageOutputBytes,
})
}
diff --git a/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts b/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts
index 7fbf1fe8..975e5c38 100644
--- a/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts
+++ b/src/vault-mcp/mcp-core/__tests__/mcp-router.test.ts
@@ -71,7 +71,7 @@ const SERVER_INFO = {
}
const SERVER_OPTIONS = {
- instructions: `Read, write, and search an Obsidian vault. Use vault_search and vault_read_note to find and read notes. Use vault_get_memory to retrieve user preferences and context from ${DEFAULT_CONFIG.memoryDir}/ files. Use vault_write_note and vault_update_memory for writes.
+ instructions: `Read, write, and search an Obsidian vault. Use vault_search and vault_read_note to find and read notes; vault_read_asset for images, canvases, and other non-markdown files. Use vault_get_memory to retrieve user preferences and context from ${DEFAULT_CONFIG.memoryDir}/ files. Use vault_write_note and vault_update_memory for writes.
Vault content is Obsidian Flavored Markdown. Write tools pass content through without escaping — be intentional about Obsidian syntax (#, [[, %%, etc.) in inputs.`,
}
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 0182b435..6aac6409 100644
--- a/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts
+++ b/src/vault-mcp/mcp-core/__tests__/tool-definitions.test.ts
@@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach, vi, onTestFinished } from "vitest"
+import sharp from "sharp"
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"
import { join } from "node:path"
import { tmpdir } from "node:os"
@@ -31,6 +32,8 @@ const READ_ONLY_TOOLS = [
TOOL_NAMES.VAULT_GET_BACKLINKS,
TOOL_NAMES.VAULT_GET_OUTGOING_LINKS,
TOOL_NAMES.VAULT_FIND_ORPHANS,
+ TOOL_NAMES.VAULT_READ_ASSET,
+ TOOL_NAMES.VAULT_LIST_ASSETS,
] as const
const DESTRUCTIVE_TOOLS = [
@@ -264,6 +267,28 @@ describe("registerTools", () => {
expect(config.description).toContain("vault_get_backlinks")
})
+ it("vault_read_asset description cross-references discovery and note tools", () => {
+ const [, config] = requireCall(TOOL_NAMES.VAULT_READ_ASSET)
+ expect(config.description).toContain("vault_list_assets")
+ expect(config.description).toContain("vault_get_outgoing_links")
+ expect(config.description).toContain("vault_read_note")
+ })
+
+ it("vault_list_assets description cross-references vault_read_asset", () => {
+ const [, config] = requireCall(TOOL_NAMES.VAULT_LIST_ASSETS)
+ expect(config.description).toContain("vault_read_asset")
+ })
+
+ it("vault_read_note description routes non-md paths to vault_read_asset", () => {
+ const [, config] = requireCall(TOOL_NAMES.VAULT_READ_NOTE)
+ expect(config.description).toContain("vault_read_asset")
+ })
+
+ it("vault_get_outgoing_links description cross-references vault_read_asset", () => {
+ const [, config] = requireCall(TOOL_NAMES.VAULT_GET_OUTGOING_LINKS)
+ expect(config.description).toContain("vault_read_asset")
+ })
+
it("every tool has all 4 annotation hints", () => {
for (const [, config] of calls) {
const annotations = config.annotations!
@@ -916,3 +941,284 @@ describe("vault_list_tasks handler", () => {
})
})
})
+
+describe("asset tool handlers", () => {
+ const mockExtra = { requestId: "test-1", sessionId: "session-1" }
+
+ type HandlerResult = {
+ content: Array<{
+ type: string
+ text?: string
+ data?: string
+ mimeType?: string
+ }>
+ isError?: boolean
+ }
+
+ /** Registers against a real temp vault and returns the two asset handlers —
+ * the global harness registers against a nonexistent path. */
+ const setupAssetHarness = async (): Promise<{
+ vault: string
+ readAsset: (args: unknown) => Promise
+ listAssets: (args: unknown) => Promise
+ }> => {
+ const tempVault = await mkdtemp(join(tmpdir(), "tool-definitions-assets-"))
+ onTestFinished(() => rm(tempVault, { recursive: true, force: true }))
+ const server = { registerTool: vi.fn() }
+ registerTools({
+ server: server as unknown as McpServer,
+ vaultPath: tempVault,
+ search: {} as SearchIndex,
+ logger,
+ config: loadConfig({}),
+ })
+ const registeredCalls = server.registerTool.mock.calls as RegisterToolCall[]
+ const handlerFor = (
+ name: string,
+ ): ((args: unknown) => Promise) => {
+ const call = registeredCalls.find(([toolName]) => toolName === name)
+ if (!call) throw new Error(`tool not registered: ${name}`)
+ const [, , handler] = call
+ return async (args: unknown) =>
+ (await handler(args, mockExtra)) as HandlerResult
+ }
+ return {
+ vault: tempVault,
+ readAsset: handlerFor(TOOL_NAMES.VAULT_READ_ASSET),
+ listAssets: handlerFor(TOOL_NAMES.VAULT_LIST_ASSETS),
+ }
+ }
+
+ it("returns a .canvas file as its linearized rendition", async () => {
+ const { vault, readAsset } = await setupAssetHarness()
+ await writeFile(
+ join(vault, "Board.canvas"),
+ JSON.stringify({
+ nodes: [
+ {
+ id: "a",
+ type: "text",
+ x: 0,
+ y: 0,
+ width: 200,
+ height: 100,
+ text: "hello",
+ },
+ ],
+ edges: [],
+ }),
+ "utf8",
+ )
+ const result = await readAsset({ path: "Board.canvas" })
+ expect(result.isError).toBeUndefined()
+ expect(result.content).toEqual([
+ { type: "text", text: "# Canvas: 1 node, 0 edges\n\n[text]\nhello" },
+ ])
+ })
+
+ it.each([
+ { extension: "json", content: '{"key": "value"}' },
+ { extension: "svg", content: '' },
+ { extension: "csv", content: "a,b\n1,2\n" },
+ { extension: "txt", content: "plain text\n" },
+ { extension: "xml", content: "" },
+ { extension: "log", content: "line one\nline two\n" },
+ { extension: "base", content: "views:\n - type: table\n" },
+ ])(
+ "returns a .$extension file verbatim as text",
+ async ({ extension, content }) => {
+ const { vault, readAsset } = await setupAssetHarness()
+ await writeFile(join(vault, `file.${extension}`), content, "utf8")
+ const result = await readAsset({ path: `file.${extension}` })
+ expect(result.isError).toBeUndefined()
+ expect(result.content).toEqual([{ type: "text", text: content }])
+ },
+ )
+
+ it("returns a small PNG as an image block with a metadata text line", async () => {
+ const { vault, readAsset } = await setupAssetHarness()
+ const png = await sharp({
+ create: {
+ width: 4,
+ height: 4,
+ channels: 3,
+ background: { r: 255, g: 0, b: 255 },
+ },
+ })
+ .png()
+ .toBuffer()
+ await writeFile(join(vault, "tiny.png"), png)
+ const result = await readAsset({ path: "tiny.png" })
+ expect(result.isError).toBeUndefined()
+ expect(result.content).toEqual([
+ { type: "image", data: png.toString("base64"), mimeType: "image/png" },
+ {
+ type: "text",
+ text: `tiny.png — image/png, 4×4, ${png.length} bytes (original file, not recompressed)`,
+ },
+ ])
+ })
+
+ it("returns the exact canvas JSON source when raw is set", async () => {
+ const { vault, readAsset } = await setupAssetHarness()
+ const canvasSource = JSON.stringify({
+ nodes: [
+ {
+ id: "a",
+ type: "text",
+ x: 0,
+ y: 0,
+ width: 200,
+ height: 100,
+ text: "hello",
+ },
+ ],
+ edges: [],
+ })
+ await writeFile(join(vault, "Board.canvas"), canvasSource, "utf8")
+ const result = await readAsset({ path: "Board.canvas", raw: true })
+ expect(result.isError).toBeUndefined()
+ expect(result.content).toEqual([{ type: "text", text: canvasSource }])
+ })
+
+ it("rejects raw for an image", async () => {
+ const { vault, readAsset } = await setupAssetHarness()
+ const png = await sharp({
+ create: {
+ width: 4,
+ height: 4,
+ channels: 3,
+ background: { r: 255, g: 0, b: 255 },
+ },
+ })
+ .png()
+ .toBuffer()
+ await writeFile(join(vault, "tiny.png"), png)
+ const result = await readAsset({ path: "tiny.png", raw: true })
+ expect(result.isError).toBe(true)
+ expect(result.content[0]?.text).toBe(
+ '[Error]: raw source is not available for images: "tiny.png" is binary — its image block is the delivered form',
+ )
+ })
+
+ it("returns a text format's source unchanged when raw is set", async () => {
+ const { vault, readAsset } = await setupAssetHarness()
+ await writeFile(join(vault, "data.json"), '{"key": "value"}', "utf8")
+ const result = await readAsset({ path: "data.json", raw: true })
+ expect(result.isError).toBeUndefined()
+ expect(result.content).toEqual([{ type: "text", text: '{"key": "value"}' }])
+ })
+
+ it("rejects a .pdf with a not-yet-supported error carrying the file size", async () => {
+ const { vault, readAsset } = await setupAssetHarness()
+ await writeFile(join(vault, "doc.pdf"), "0123456789", "utf8")
+ const result = await readAsset({ path: "doc.pdf" })
+ expect(result.isError).toBe(true)
+ expect(result.content[0]?.text).toBe(
+ '[Error]: PDF reading is not yet supported: "doc.pdf" exists (10 bytes) but text extraction is not available yet',
+ )
+ })
+
+ it("rejects an unsupported extension naming the readable types", async () => {
+ const { vault, readAsset } = await setupAssetHarness()
+ await writeFile(join(vault, "song.mp3"), "xxxx", "utf8")
+ const result = await readAsset({ path: "song.mp3" })
+ expect(result.isError).toBe(true)
+ expect(result.content[0]?.text).toBe(
+ '[Error]: unsupported asset type ".mp3": "song.mp3" exists (4 bytes). Readable types: images (.png/.jpg/.jpeg/.gif/.webp), .canvas, and text formats (.svg/.json/.txt/.csv/.xml/.log/.base)',
+ )
+ })
+
+ 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")
+ const result = await readAsset({ path: "note.md" })
+ expect(result.isError).toBe(true)
+ expect(result.content[0]?.text).toBe(
+ '[Error]: not an asset: "note.md" is a markdown note',
+ )
+ })
+
+ it("rejects a missing asset with asset not found", async () => {
+ const { readAsset } = await setupAssetHarness()
+ const result = await readAsset({ path: "ghost.png" })
+ expect(result.isError).toBe(true)
+ expect(result.content[0]?.text).toBe(
+ '[Error]: asset not found: "ghost.png"',
+ )
+ })
+
+ it("rejects a non-UTF-8 text asset instead of corrupting it", async () => {
+ const { vault, readAsset } = await setupAssetHarness()
+ // 0xFF is never valid in UTF-8 — the default decoder would silently
+ // substitute U+FFFD; the tool must refuse instead.
+ await writeFile(join(vault, "latin1.txt"), Buffer.from([0x68, 0x69, 0xff]))
+ const result = await readAsset({ path: "latin1.txt" })
+ expect(result.isError).toBe(true)
+ expect(result.content[0]?.text).toBe(
+ '[Error]: not valid UTF-8: "latin1.txt" cannot be returned as text',
+ )
+ })
+
+ it("rejects an oversized text asset instead of truncating it", async () => {
+ const { vault, readAsset } = await setupAssetHarness()
+ const oversized = "x".repeat(102_401)
+ await writeFile(join(vault, "big.txt"), oversized, "utf8")
+ const result = await readAsset({ path: "big.txt" })
+ expect(result.isError).toBe(true)
+ expect(result.content[0]?.text).toBe(
+ '[Error]: text output too large: "big.txt" renders to 102401 bytes (cap 102400 bytes)',
+ )
+ })
+
+ it("lists a folder's assets with bytes and counts, excluding other folders and notes", async () => {
+ const { vault, listAssets } = await setupAssetHarness()
+ await mkdir(join(vault, "media"), { recursive: true })
+ await mkdir(join(vault, "elsewhere"), { recursive: true })
+ await writeFile(join(vault, "media/a.png"), "12345", "utf8")
+ await writeFile(join(vault, "media/b.canvas"), "{}", "utf8")
+ await writeFile(join(vault, "media/note.md"), "# not an asset", "utf8")
+ await writeFile(join(vault, "elsewhere/c.png"), "999", "utf8")
+ const result = await listAssets({ folder: "media" })
+ expect(result.isError).toBeUndefined()
+ expect(JSON.parse(result.content[0]?.text ?? "")).toEqual({
+ assets: [
+ { path: "media/a.png", extension: ".png", bytes: 5 },
+ { path: "media/b.canvas", extension: ".canvas", bytes: 2 },
+ ],
+ extension_counts: { ".png": 1, ".canvas": 1 },
+ total: 2,
+ truncated: false,
+ })
+ })
+
+ it.each(["PNG", ".PNG"])(
+ "filters by extension case-insensitively for %s",
+ async (extensionSpelling) => {
+ const { vault, listAssets } = await setupAssetHarness()
+ await writeFile(join(vault, "a.png"), "12345", "utf8")
+ await writeFile(join(vault, "b.jpg"), "12", "utf8")
+ const result = await listAssets({ extensions: [extensionSpelling] })
+ expect(JSON.parse(result.content[0]?.text ?? "")).toEqual({
+ assets: [{ path: "a.png", extension: ".png", bytes: 5 }],
+ extension_counts: { ".png": 1 },
+ total: 1,
+ truncated: false,
+ })
+ },
+ )
+
+ it("pages with limit while counts and total cover the full filtered set", async () => {
+ const { vault, listAssets } = await setupAssetHarness()
+ await writeFile(join(vault, "a.png"), "1", "utf8")
+ await writeFile(join(vault, "b.png"), "22", "utf8")
+ await writeFile(join(vault, "c.jpg"), "333", "utf8")
+ const result = await listAssets({ limit: 1 })
+ expect(JSON.parse(result.content[0]?.text ?? "")).toEqual({
+ assets: [{ path: "a.png", extension: ".png", bytes: 1 }],
+ extension_counts: { ".png": 2, ".jpg": 1 },
+ total: 3,
+ truncated: true,
+ })
+ })
+})
diff --git a/src/vault-mcp/mcp-core/__tests__/vault-orientation-prompt.test.ts b/src/vault-mcp/mcp-core/__tests__/vault-orientation-prompt.test.ts
index 845bc3fe..25d4a699 100644
--- a/src/vault-mcp/mcp-core/__tests__/vault-orientation-prompt.test.ts
+++ b/src/vault-mcp/mcp-core/__tests__/vault-orientation-prompt.test.ts
@@ -321,6 +321,7 @@ describe("vault-orientation full prompt output", () => {
"- `vault_find_orphans` — full orphan list with exclusion control",
"- `vault_get_memory` — read memory files in detail",
"- `vault_read_note` — read any note's full content",
+ "- `vault_list_assets` — browse non-markdown files (images, canvases, data files)",
].join("\n"),
)
})
diff --git a/src/vault-mcp/mcp-core/mcp-router.ts b/src/vault-mcp/mcp-core/mcp-router.ts
index 30af05f4..06bb9fac 100644
--- a/src/vault-mcp/mcp-core/mcp-router.ts
+++ b/src/vault-mcp/mcp-core/mcp-router.ts
@@ -95,10 +95,10 @@ export const createMcpRouter = ({
},
{
instructions: config.memoryEnabled
- ? `Read, write, and search an Obsidian vault. Use vault_search and vault_read_note to find and read notes. Use vault_get_memory to retrieve user preferences and context from ${config.memoryDir}/ files. Use vault_write_note and vault_update_memory for writes.
+ ? `Read, write, and search an Obsidian vault. Use vault_search and vault_read_note to find and read notes; vault_read_asset for images, canvases, and other non-markdown files. Use vault_get_memory to retrieve user preferences and context from ${config.memoryDir}/ files. Use vault_write_note and vault_update_memory for writes.
Vault content is Obsidian Flavored Markdown. Write tools pass content through without escaping — be intentional about Obsidian syntax (#, [[, %%, etc.) in inputs.`
- : `Read, write, and search an Obsidian vault. Use vault_search and vault_read_note to find and read notes. Use vault_write_note for writes.
+ : `Read, write, and search an Obsidian vault. Use vault_search and vault_read_note to find and read notes; vault_read_asset for images, canvases, and other non-markdown files. Use vault_write_note for writes.
Vault content is Obsidian Flavored Markdown. Write tools pass content through without escaping — be intentional about Obsidian syntax (#, [[, %%, etc.) in inputs.`,
},
diff --git a/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts b/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts
index 48d9409e..7ed781b9 100644
--- a/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts
+++ b/src/vault-mcp/mcp-core/prompts/vault-orientation-prompt.ts
@@ -247,6 +247,7 @@ export const registerVaultOrientationPrompt = ({
orphanTools,
memoryTools,
"- `vault_read_note` — read any note's full content",
+ "- `vault_list_assets` — browse non-markdown files (images, canvases, data files)",
]
.filter(Boolean)
.join("\n")
diff --git a/src/vault-mcp/mcp-core/tool-definitions.ts b/src/vault-mcp/mcp-core/tool-definitions.ts
index c321dfb4..2b68805f 100644
--- a/src/vault-mcp/mcp-core/tool-definitions.ts
+++ b/src/vault-mcp/mcp-core/tool-definitions.ts
@@ -15,6 +15,7 @@ import {
registerDailyNoteTools,
} from "./tools/daily-note-tools.js"
import { TASK_TOOL_NAMES, registerTaskTools } from "./tools/task-tools.js"
+import { ASSET_TOOL_NAMES, registerAssetTools } from "./tools/asset-tools.js"
export const TOOL_NAMES = {
...VAULT_CRUD_TOOL_NAMES,
@@ -22,6 +23,7 @@ export const TOOL_NAMES = {
...MEMORY_TOOL_NAMES,
...DAILY_NOTE_TOOL_NAMES,
...TASK_TOOL_NAMES,
+ ...ASSET_TOOL_NAMES,
} as const
export const registerTools = (params: {
@@ -38,12 +40,14 @@ export const registerTools = (params: {
}
registerDailyNoteTools(params)
registerTaskTools(params)
+ registerAssetTools(params)
const registeredCount =
Object.keys(VAULT_CRUD_TOOL_NAMES).length +
Object.keys(SEARCH_TOOL_NAMES).length +
(params.config.memoryEnabled ? Object.keys(MEMORY_TOOL_NAMES).length : 0) +
Object.keys(DAILY_NOTE_TOOL_NAMES).length +
- Object.keys(TASK_TOOL_NAMES).length
+ Object.keys(TASK_TOOL_NAMES).length +
+ Object.keys(ASSET_TOOL_NAMES).length
params.logger.info("registered tools", { count: registeredCount })
}
diff --git a/src/vault-mcp/mcp-core/tools/asset-tools.ts b/src/vault-mcp/mcp-core/tools/asset-tools.ts
new file mode 100644
index 00000000..5ec4bb1d
--- /dev/null
+++ b/src/vault-mcp/mcp-core/tools/asset-tools.ts
@@ -0,0 +1,220 @@
+/** Asset tool registration — reading and discovering non-markdown vault files. */
+
+import { z } from "zod"
+import { assetOperations } from "../../vault-operations/asset-operations.js"
+import type { FittedImage } from "../../../utils/fit-image-to-byte-budget.js"
+import type { ToolRegistrationContext } from "./tool-helpers.js"
+import { safeHandler, safeHandlerContent } from "./tool-helpers.js"
+
+const TOOL_NAMES = {
+ VAULT_READ_ASSET: "vault_read_asset",
+ VAULT_LIST_ASSETS: "vault_list_assets",
+} as const
+
+export { TOOL_NAMES as ASSET_TOOL_NAMES }
+
+/** One-line, model-facing summary accompanying an image block: what file it
+ * is, what was delivered, and whether/how it was shrunk to fit. */
+const describeDeliveredImage = (result: {
+ fitted: FittedImage
+ originalBytes: number
+ path: string
+}): string => {
+ const { fitted, originalBytes, path } = result
+ const delivered = `${path} — ${fitted.mimeType}, ${fitted.width}×${fitted.height}, ${fitted.data.length} bytes`
+ if (!fitted.recompressed)
+ return `${delivered} (original file, not recompressed)`
+ return `${delivered} (recompressed from ${fitted.originalWidth}×${fitted.originalHeight}, ${originalBytes} bytes)`
+}
+
+export const registerAssetTools = ({
+ server,
+ vaultPath,
+ logger: sessionLogger,
+ config,
+}: ToolRegistrationContext): void => {
+ server.registerTool(
+ TOOL_NAMES.VAULT_READ_ASSET,
+ {
+ title: "Read Asset",
+ description: `Read a non-markdown vault file (an asset) in its most useful form per type — the read-side companion to vault_read_note for everything that isn't a note.
+
+Example: vault_read_asset({ path: "attachments/diagram.png" }) — the image itself, shrunk to fit response limits when needed
+Example: vault_read_asset({ path: "Boards/Roadmap.canvas" }) — a readable outline of the canvas
+Example: vault_read_asset({ path: "Boards/Roadmap.canvas", raw: true }) — the canvas's exact JSON source
+Example: vault_read_asset({ path: "exports/data.json" }) — the file content as text
+
+What each type returns:
+- Images (.png/.jpg/.jpeg/.gif/.webp): the image as a viewable image block — downscaled and recompressed server-side when it exceeds client response limits, delivered untouched otherwise — plus a text line stating the path, delivered format/dimensions/bytes, and the original dimensions when shrunk. Animated GIFs are reduced to their first frame when recompressed to fit the budget.
+- Canvas (.canvas): a readable markdown outline per JSON Canvas 1.0 — groups (by visual containment), node content in reading order, and a connections list with edge labels. Set raw: true for the exact JSON source instead (geometry, ids, colors — full fidelity).
+- Text formats (.svg/.json/.txt/.csv/.xml/.log/.base): the file content verbatim as text. .svg is returned as its XML source; .base as its YAML source.
+- PDFs (.pdf): not yet readable — returns an error that confirms the file exists and its size; text extraction is planned.
+
+When to use: whenever a note references an asset you need to actually see or read — an embedded diagram, a linked canvas or data file. Find the assets a note links to (with byte sizes) via vault_get_outgoing_links; browse a folder's assets via vault_list_assets. For .md notes use vault_read_note — this tool rejects them.
+
+Errors:
+- "not an asset" — the path ends in .md; read notes with vault_read_note
+- "asset not found" — nothing exists at that path; discover valid paths via vault_list_assets
+- "asset too large" — the file exceeds the server's read cap (MAX_ASSET_BYTES, default 50 MiB)
+- "text output too large" — a text asset renders past the output cap; only smaller files can be returned whole
+- "not valid UTF-8" — the file's bytes aren't UTF-8 text; returning them would silently corrupt the content
+- "image cannot be fitted" — the image could not be compressed under the output budget (MAX_IMAGE_OUTPUT_BYTES)
+- "raw source is not available for images" — raw applies to text-representable files; an image's delivered form is its image block
+- unsupported types (audio, archives, …) return an error naming the readable types plus the file's existence and size
+
+Returns: for images, an image content block plus a one-line metadata text block; for every other supported type, a single text content block.
+
+Search coverage: vault_search indexes markdown notes; find assets by browsing (vault_list_assets) or through a note's links (vault_get_outgoing_links).`,
+ inputSchema: {
+ path: z
+ .string()
+ .min(1)
+ .describe(
+ 'Vault-relative path to the asset, including its extension (e.g. "attachments/photo.png", "Boards/Roadmap.canvas"). Must NOT end in ".md" — notes are read with vault_read_note.',
+ ),
+ raw: z
+ .boolean()
+ .optional()
+ .describe(
+ "Return the file's exact text source instead of any rendered form. For .canvas this is the JSON Canvas source (geometry, ids, colors); text formats already return their source, so raw changes nothing there. Images have no text source — raw returns an error.",
+ ),
+ },
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ },
+ async ({ path, raw }, extra) => {
+ const reqLogger = sessionLogger.child({
+ requestId: extra.requestId,
+ tool: TOOL_NAMES.VAULT_READ_ASSET,
+ })
+ reqLogger.info("tool_call", { path, raw })
+ return safeHandlerContent(
+ reqLogger,
+ () =>
+ assetOperations.readAssetContent(
+ {
+ vaultPath,
+ path,
+ raw,
+ maxAssetBytes: config.maxAssetBytes,
+ maxImageOutputBytes: config.maxImageOutputBytes,
+ },
+ reqLogger,
+ ),
+ (result) => {
+ if (result.kind === "image") {
+ reqLogger.info("tool_result", {
+ path,
+ mimeType: result.fitted.mimeType,
+ deliveredBytes: result.fitted.data.length,
+ originalBytes: result.originalBytes,
+ width: result.fitted.width,
+ height: result.fitted.height,
+ originalWidth: result.fitted.originalWidth,
+ originalHeight: result.fitted.originalHeight,
+ recompressed: result.fitted.recompressed,
+ })
+ return [
+ {
+ type: "image" as const,
+ data: result.fitted.data.toString("base64"),
+ mimeType: result.fitted.mimeType,
+ },
+ { type: "text" as const, text: describeDeliveredImage(result) },
+ ]
+ }
+ reqLogger.info("tool_result", {
+ path,
+ textBytes: Buffer.byteLength(result.text, "utf8"),
+ })
+ return [{ type: "text" as const, text: result.text }]
+ },
+ )
+ },
+ )
+
+ server.registerTool(
+ TOOL_NAMES.VAULT_LIST_ASSETS,
+ {
+ title: "List Assets",
+ description: `List non-markdown files (assets) in the vault or a folder — images, canvases, PDFs, data files — with per-file byte sizes and per-extension counts.
+
+Example: vault_list_assets({}) — every asset in the vault
+Example: vault_list_assets({ folder: "attachments" })
+Example: vault_list_assets({ extensions: [".png", ".jpg"], limit: 20 })
+
+When to use: discovering what assets exist before reading them with vault_read_asset. vault_search, vault_list_notes, and vault_search_by_folder cover only markdown notes, so this is the discovery surface for everything else. For the assets one specific note links to, prefer vault_get_outgoing_links.
+
+Parameters:
+- folder: folder path filter (e.g. "attachments" or "Projects/media"), searched recursively; omit for the whole vault
+- extensions: restrict to these extensions — case-insensitive, with or without the leading dot (".png" and "png" both work)
+- limit: maximum entries returned (default 50). extension_counts and total always reflect the full filtered set, not just the returned page.
+
+Errors:
+- A folder containing no assets — or a folder that doesn't exist — returns an empty listing, not an error.
+- A folder path escaping the vault (e.g. "../elsewhere") is rejected with a path-traversal error.
+
+Returns: JSON with assets (array of { path, extension, bytes }, sorted by path), extension_counts (per-extension totals over the full filtered set), total (full filtered count), and truncated (true when total exceeds limit). bytes is the on-disk file size, not the delivery cost: reading an image via vault_read_asset returns a copy shrunk to fit when needed, so a large listed image is still cheap to read. Text formats return verbatim, so their listed size is what a read delivers. Assets of supported types are readable via vault_read_asset (unsupported types like .pdf return a descriptive error there); vault_search covers markdown notes.`,
+ inputSchema: {
+ folder: z
+ .string()
+ .min(1)
+ .optional()
+ .describe(
+ 'Folder path to search recursively (e.g. "attachments"). Omit to list the whole vault.',
+ ),
+ extensions: z
+ .array(z.string().min(1))
+ .optional()
+ .describe(
+ 'Only include these extensions, case-insensitive, leading dot optional (e.g. [".png", "jpg"]).',
+ ),
+ limit: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe("Max entries returned (default 50)."),
+ },
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ },
+ async ({ folder, extensions, limit }, extra) => {
+ const reqLogger = sessionLogger.child({
+ requestId: extra.requestId,
+ tool: TOOL_NAMES.VAULT_LIST_ASSETS,
+ })
+ reqLogger.info("tool_call", { folder, extensions, limit })
+ return safeHandler(
+ reqLogger,
+ async () => {
+ const listing = await assetOperations.buildAssetListing(
+ { vaultPath, folder, extensions, limit: limit ?? 50 },
+ reqLogger,
+ )
+ return {
+ assets: listing.assets,
+ extension_counts: listing.extensionCounts,
+ total: listing.total,
+ truncated: listing.truncated,
+ }
+ },
+ (result) => {
+ reqLogger.info("tool_result", {
+ total: result.total,
+ returned: result.assets.length,
+ })
+ return JSON.stringify(result)
+ },
+ )
+ },
+ )
+}
diff --git a/src/vault-mcp/mcp-core/tools/search-tools.ts b/src/vault-mcp/mcp-core/tools/search-tools.ts
index 1cf5460b..13b756cb 100644
--- a/src/vault-mcp/mcp-core/tools/search-tools.ts
+++ b/src/vault-mcp/mcp-core/tools/search-tools.ts
@@ -618,7 +618,7 @@ For incoming links (what links TO a note), use vault_get_backlinks.
Parameters:
- path is matched against the search index, so the note must be indexed (file watcher processes new/moved files within seconds). A path not in the index returns an empty result (count 0), not an error — indistinguishable from a note with no outbound links.
-Returns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes } sorted by target path), and count. Each link carries exists (boolean) and kind ("note"|"asset"): exists+note = readable via vault_read_note; exists+asset = non-markdown file (.canvas, image, PDF); !exists+note = broken link. bytes is null for broken links and assets.`,
+Returns: JSON with path, outgoing_links (array of { path, title, exists, kind, bytes } sorted by target path), and count. Each link carries exists (boolean) and kind ("note"|"asset"): exists+note = readable via vault_read_note; exists+asset = non-markdown file (.canvas, image, PDF) readable via vault_read_asset; !exists+note = broken link. bytes is the on-disk file size for notes and assets alike (null for broken links) — not the delivery cost: vault_read_asset downscales images to fit response limits, so a large image asset is still cheap to read.`,
inputSchema: {
path: z
.string()
diff --git a/src/vault-mcp/mcp-core/tools/tool-helpers.ts b/src/vault-mcp/mcp-core/tools/tool-helpers.ts
index d0264c89..116ee575 100644
--- a/src/vault-mcp/mcp-core/tools/tool-helpers.ts
+++ b/src/vault-mcp/mcp-core/tools/tool-helpers.ts
@@ -2,6 +2,7 @@
import { z } from "zod"
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
+import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import type { SearchIndex } from "../../search/search-index.js"
import type { VaultConfig } from "../../config.js"
import type { Logger } from "../../../logger.js"
@@ -62,20 +63,20 @@ export const dateFilterSchema = z
})
.optional()
-/** Wraps a handler with try/catch, returning isError on failure. */
-export const safeHandler = async (
+/** Wraps a handler with try/catch, returning isError on failure. The format
+ * callback produces the full content-block array — text, image, or mixed
+ * (the SDK union) — for tools whose results aren't a single text block. */
+export const safeHandlerContent = async (
logger: Logger,
fn: () => Promise,
- format: (result: T) => string,
+ format: (result: T) => CallToolResult["content"],
): Promise<{
- content: Array<{ type: "text"; text: string }>
+ content: CallToolResult["content"]
isError?: true
}> => {
try {
const result = await fn()
- return {
- content: [{ type: "text" as const, text: format(result) }],
- }
+ return { content: format(result) }
} catch (err) {
const message = describeError(err)
logger.warn("tool_error", { error: message })
@@ -85,3 +86,18 @@ export const safeHandler = async (
}
}
}
+
+/** Wraps a handler with try/catch, returning isError on failure — the common
+ * single-text-block case. Delegates to safeHandlerContent so the error
+ * contract (describeError, tool_error log, isError) has exactly one home. */
+export const safeHandler = (
+ logger: Logger,
+ fn: () => Promise,
+ format: (result: T) => string,
+): Promise<{
+ content: CallToolResult["content"]
+ isError?: true
+}> =>
+ safeHandlerContent(logger, fn, (result) => [
+ { type: "text" as const, text: format(result) },
+ ])
diff --git a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts
index 5f2a8591..3bd4d000 100644
--- a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts
+++ b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts
@@ -52,6 +52,7 @@ Errors:
- "heading not found" — no heading matches the text; error lists available headings
- "ambiguous heading" — multiple headings match; use heading_level to disambiguate
- "outline, heading, and properties_only are mutually exclusive" — only one mode per call
+- 'path must end in ".md"' — the path names a non-markdown file; read assets (images, .canvas, data files) with vault_read_asset instead
Returns: Raw markdown string (default); JSON object of properties (properties_only); JSON outline object (outline); raw markdown of the section, heading line included (heading).
diff --git a/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts
new file mode 100644
index 00000000..e2071392
--- /dev/null
+++ b/src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts
@@ -0,0 +1,313 @@
+import { describe, it, expect } from "vitest"
+import { linearizeCanvas } from "../canvas.js"
+
+/** Minimal node factories — geometry defaults keep tests focused on the
+ * fields under test. */
+const textNode = (
+ overrides: Partial<{
+ id: string
+ x: number
+ y: number
+ width: number
+ height: number
+ text: string
+ }>,
+): Record => ({
+ id: "text-1",
+ type: "text",
+ x: 0,
+ y: 0,
+ width: 200,
+ height: 100,
+ text: "hello",
+ ...overrides,
+})
+
+const canvasJson = (
+ nodes: Record[],
+ edges: Record[] = [],
+): string => JSON.stringify({ nodes, edges })
+
+describe("linearizeCanvas", () => {
+ it("renders ungrouped text, file, and link nodes with their content", () => {
+ const json = canvasJson([
+ textNode({ id: "a", text: "## Ideas\n- ship it" }),
+ {
+ id: "b",
+ type: "file",
+ x: 300,
+ y: 0,
+ width: 400,
+ height: 400,
+ file: "Diagrams/arch.png",
+ },
+ {
+ id: "c",
+ type: "link",
+ x: 0,
+ y: 200,
+ width: 200,
+ height: 100,
+ url: "https://example.com",
+ },
+ ])
+ expect(linearizeCanvas(json)).toBe(
+ [
+ "# Canvas: 3 nodes, 0 edges",
+ "[text]\n## Ideas\n- ship it",
+ "[file] → Diagrams/arch.png",
+ "[link] → https://example.com",
+ ].join("\n\n"),
+ )
+ })
+
+ it("orders nodes top-to-bottom then left-to-right, not by JSON order", () => {
+ const json = canvasJson([
+ textNode({ id: "bottom", y: 500, text: "third" }),
+ textNode({ id: "top-right", x: 300, y: 0, text: "second" }),
+ textNode({ id: "top-left", x: 0, y: 0, text: "first" }),
+ ])
+ expect(linearizeCanvas(json)).toBe(
+ [
+ "# Canvas: 3 nodes, 0 edges",
+ "[text]\nfirst",
+ "[text]\nsecond",
+ "[text]\nthird",
+ ].join("\n\n"),
+ )
+ })
+
+ it("assigns a node to its smallest containing group and nests child groups", () => {
+ const json = canvasJson([
+ {
+ id: "outer",
+ type: "group",
+ label: "Outer",
+ x: 0,
+ y: 0,
+ width: 1000,
+ height: 1000,
+ },
+ {
+ id: "inner",
+ type: "group",
+ label: "Inner",
+ x: 10,
+ y: 10,
+ width: 500,
+ height: 500,
+ },
+ textNode({ id: "member", x: 20, y: 20, text: "in the inner group" }),
+ ])
+ expect(linearizeCanvas(json)).toBe(
+ [
+ "# Canvas: 3 nodes, 0 edges",
+ "## Group: Outer",
+ "### Group: Inner",
+ "[text]\nin the inner group",
+ ].join("\n\n"),
+ )
+ })
+
+ it("renders a node overlapping but not contained by a group as ungrouped", () => {
+ const json = canvasJson([
+ {
+ id: "group",
+ type: "group",
+ label: "Box",
+ x: 0,
+ y: 0,
+ width: 300,
+ height: 300,
+ },
+ // Straddles the group's right edge — overlap without containment.
+ textNode({ id: "straddler", x: 250, y: 10, width: 200, text: "outside" }),
+ ])
+ expect(linearizeCanvas(json)).toBe(
+ ["# Canvas: 2 nodes, 0 edges", "[text]\noutside", "## Group: Box"].join(
+ "\n\n",
+ ),
+ )
+ })
+
+ it("resolves edge endpoints to display names with the edge label appended", () => {
+ const json = canvasJson(
+ [
+ textNode({ id: "a", text: "## Ideas\n- ship it" }),
+ {
+ id: "b",
+ type: "file",
+ x: 300,
+ y: 0,
+ width: 400,
+ height: 400,
+ file: "Diagrams/arch.png",
+ },
+ ],
+ [{ id: "e1", fromNode: "a", toNode: "b", label: "references" }],
+ )
+ expect(linearizeCanvas(json)).toBe(
+ [
+ "# Canvas: 2 nodes, 1 edge",
+ "[text]\n## Ideas\n- ship it",
+ "[file] → Diagrams/arch.png",
+ "## Connections\n\nIdeas → arch.png (references)",
+ ].join("\n\n"),
+ )
+ })
+
+ it("marks a dangling edge endpoint instead of throwing", () => {
+ const json = canvasJson(
+ [textNode({ id: "a", text: "alone" })],
+ [{ id: "e1", fromNode: "a", toNode: "ghost" }],
+ )
+ expect(linearizeCanvas(json)).toBe(
+ [
+ "# Canvas: 1 node, 1 edge",
+ "[text]\nalone",
+ '## Connections\n\nalone → (missing node "ghost")',
+ ].join("\n\n"),
+ )
+ })
+
+ it("appends a file node's subpath to its path", () => {
+ const json = canvasJson([
+ {
+ id: "f",
+ type: "file",
+ x: 0,
+ y: 0,
+ width: 400,
+ height: 300,
+ file: "Notes/Plan.md",
+ subpath: "#Goals",
+ },
+ ])
+ expect(linearizeCanvas(json)).toBe(
+ ["# Canvas: 1 node, 0 edges", "[file] → Notes/Plan.md#Goals"].join(
+ "\n\n",
+ ),
+ )
+ })
+
+ it("skips entries missing required fields and ignores unknown properties", () => {
+ const json = canvasJson(
+ [
+ { id: "no-geometry", type: "text", text: "dropped" },
+ textNode({ id: "kept", text: "kept" }),
+ {
+ ...textNode({ id: "extras", x: 0, y: 200, text: "with extras" }),
+ color: "1",
+ zIndex: 5,
+ },
+ ],
+ [{ id: "half-edge", fromNode: "kept" }],
+ )
+ expect(linearizeCanvas(json)).toBe(
+ [
+ "# Canvas: 2 nodes, 0 edges",
+ "[text]\nkept",
+ "[text]\nwith extras",
+ ].join("\n\n"),
+ )
+ })
+
+ it("renders both groups when two groups share an identical rectangle", () => {
+ // Identical rects contain each other; without a deterministic tiebreak
+ // each would claim the other as parent and both would vanish from the
+ // output (neither top-level). The higher id must contain the lower.
+ const json = canvasJson([
+ {
+ id: "alpha",
+ type: "group",
+ label: "Alpha",
+ x: 0,
+ y: 0,
+ width: 400,
+ height: 400,
+ },
+ {
+ id: "beta",
+ type: "group",
+ label: "Beta",
+ x: 0,
+ y: 0,
+ width: 400,
+ height: 400,
+ },
+ textNode({ id: "member", x: 10, y: 10, text: "inside both" }),
+ ])
+ expect(linearizeCanvas(json)).toBe(
+ [
+ "# Canvas: 3 nodes, 0 edges",
+ "## Group: Beta",
+ "### Group: Alpha",
+ "[text]\ninside both",
+ ].join("\n\n"),
+ )
+ })
+
+ it("renders the same ownership when the identical-rect groups are declared in reverse order", () => {
+ // Ownership must be a property of the canvas content (lower id wins
+ // equal-area ties), not of JSON array order.
+ const json = canvasJson([
+ {
+ id: "beta",
+ type: "group",
+ label: "Beta",
+ x: 0,
+ y: 0,
+ width: 400,
+ height: 400,
+ },
+ {
+ id: "alpha",
+ type: "group",
+ label: "Alpha",
+ x: 0,
+ y: 0,
+ width: 400,
+ height: 400,
+ },
+ textNode({ id: "member", x: 10, y: 10, text: "inside both" }),
+ ])
+ expect(linearizeCanvas(json)).toBe(
+ [
+ "# Canvas: 3 nodes, 0 edges",
+ "## Group: Beta",
+ "### Group: Alpha",
+ "[text]\ninside both",
+ ].join("\n\n"),
+ )
+ })
+
+ it("keeps an Obsidian tag's hash in edge display names", () => {
+ // Only ATX headings (hashes followed by whitespace) lose their hashes;
+ // "#project" is a tag, not a heading.
+ const json = canvasJson(
+ [
+ textNode({ id: "a", text: "#project tracking" }),
+ textNode({ id: "b", x: 300, text: "## Roadmap" }),
+ ],
+ [{ id: "e1", fromNode: "a", toNode: "b" }],
+ )
+ expect(linearizeCanvas(json)).toBe(
+ [
+ "# Canvas: 2 nodes, 1 edge",
+ "[text]\n#project tracking",
+ "[text]\n## Roadmap",
+ "## Connections\n\n#project tracking → Roadmap",
+ ].join("\n\n"),
+ )
+ })
+
+ it("renders an empty canvas as the overview line alone", () => {
+ expect(linearizeCanvas("{}")).toBe("# Canvas: 0 nodes, 0 edges")
+ })
+
+ it("throws on unparseable JSON", () => {
+ expect(() => linearizeCanvas("{not json")).toThrow(
+ /^invalid \.canvas JSON: /,
+ )
+ })
+})
diff --git a/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts
index fb64b37c..fc06c1a7 100644
--- a/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts
+++ b/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts
@@ -677,6 +677,34 @@ describe("stripExtension", () => {
})
})
+// ── getExtension ────────────────────────────────────────────────
+
+describe("getExtension", () => {
+ it("returns the extension including the dot for a normal filename", () => {
+ expect(links.getExtension("boards/Trip Route.canvas")).toBe(".canvas")
+ })
+
+ it("returns the last extension of a multi-dot filename", () => {
+ expect(links.getExtension("assets/photo.png.canvas")).toBe(".canvas")
+ })
+
+ it("returns empty string when the filename has no dot", () => {
+ expect(links.getExtension("assets/LICENSE")).toBe("")
+ })
+
+ it("treats a leading-dot file as having no extension", () => {
+ expect(links.getExtension("config/.hidden")).toBe("")
+ })
+
+ it("ignores dots in folder names", () => {
+ expect(links.getExtension("v1.2/note")).toBe("")
+ })
+
+ it("returns .png for a typical image path", () => {
+ expect(links.getExtension("attachments/photo.png")).toBe(".png")
+ })
+})
+
// ── resolveAsset ─────────────────────────────────────────────────
describe("resolveAsset", () => {
diff --git a/src/vault-mcp/obsidian-markdown/canvas.ts b/src/vault-mcp/obsidian-markdown/canvas.ts
new file mode 100644
index 00000000..66c3b8d0
--- /dev/null
+++ b/src/vault-mcp/obsidian-markdown/canvas.ts
@@ -0,0 +1,272 @@
+import { posix } from "node:path"
+
+/**
+ * Linearizes an Obsidian .canvas file (JSON Canvas 1.0 —
+ * https://jsoncanvas.org) into a readable markdown rendition.
+ *
+ * Why this exists: a canvas file is meaning buried in presentation. Its
+ * content (card text, file references) and relationships (grouping,
+ * labeled edges) are scattered through coordinate records and joined by
+ * opaque node ids — a reader consuming the raw JSON spends most of its
+ * attention on `x`/`y`/`width`/`height` noise and mental id-joins. This
+ * transform keeps exactly what carries meaning and re-expresses it as
+ * document structure: spatial grouping becomes heading nesting, canvas
+ * position becomes reading order (top-to-bottom, left-to-right), and
+ * edges become an `A → B (label)` list with ids resolved to display
+ * names. Geometry is dropped entirely — it is presentation, and the
+ * rendition is a reading surface, not a round-trippable format (callers
+ * wanting fidelity request the raw source instead).
+ *
+ * The canvas-to-document idea has community precedent — e.g. the
+ * Canvas2Document plugin (https://github.com/slnsys/obsidian-canvas2document)
+ * converts canvases to long-form documents for humans; this rendition is
+ * purpose-built for model reading, where token economy and explicit
+ * relationships matter more than layout.
+ *
+ * Lenient by design: real Obsidian canvases carry extra properties and
+ * occasionally partial entries, so unknown props are ignored and entries
+ * missing their required fields are skipped rather than thrown. Only
+ * unparseable JSON throws.
+ */
+
+/** The JSON Canvas 1.0 node shape this linearizer consumes. Only the fields
+ * it reads — unknown properties pass through untouched. */
+type CanvasNode = Readonly<{
+ id: string
+ type: string
+ x: number
+ y: number
+ width: number
+ height: number
+ text?: string | undefined
+ file?: string | undefined
+ subpath?: string | undefined
+ url?: string | undefined
+ label?: string | undefined
+}>
+
+type CanvasEdge = Readonly<{
+ fromNode: string
+ toNode: string
+ label?: string | undefined
+}>
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value)
+
+const optionalString = (value: unknown): string | undefined =>
+ typeof value === "string" ? value : undefined
+
+/** Narrows one raw array entry to a node, or null when its required JSON
+ * Canvas fields are missing/mistyped (the entry is then skipped, not thrown). */
+const parseNode = (raw: unknown): CanvasNode | null => {
+ if (!isRecord(raw)) return null
+ const { id, type, x, y, width, height } = raw
+ if (typeof id !== "string" || typeof type !== "string") return null
+ if (
+ typeof x !== "number" ||
+ typeof y !== "number" ||
+ typeof width !== "number" ||
+ typeof height !== "number"
+ ) {
+ return null
+ }
+ return {
+ id,
+ type,
+ x,
+ y,
+ width,
+ height,
+ text: optionalString(raw.text),
+ file: optionalString(raw.file),
+ subpath: optionalString(raw.subpath),
+ url: optionalString(raw.url),
+ label: optionalString(raw.label),
+ }
+}
+
+const parseEdge = (raw: unknown): CanvasEdge | null => {
+ if (!isRecord(raw)) return null
+ const { fromNode, toNode } = raw
+ if (typeof fromNode !== "string" || typeof toNode !== "string") return null
+ return { fromNode, toNode, label: optionalString(raw.label) }
+}
+
+/** True when `inner`'s rectangle lies fully inside `outer`'s — JSON Canvas
+ * group membership is spatial containment, not a structural parent field. */
+const isContainedIn = (inner: CanvasNode, outer: CanvasNode): boolean =>
+ inner.x >= outer.x &&
+ inner.y >= outer.y &&
+ inner.x + inner.width <= outer.x + outer.width &&
+ inner.y + inner.height <= outer.y + outer.height
+
+/** The group that owns a node: the smallest-area group strictly containing
+ * it (groups nest, so the smallest container is the innermost). */
+const smallestContainingGroup = (
+ node: CanvasNode,
+ groups: readonly CanvasNode[],
+): CanvasNode | undefined => {
+ const containing = groups.filter((group) => {
+ if (group.id === node.id || !isContainedIn(node, group)) return false
+ // Two groups with identical rectangles contain each other; without a
+ // tiebreak each would claim the other as parent, neither would be
+ // top-level, and both would drop out of the render entirely. Give the
+ // containment one deterministic direction (higher id contains lower)
+ // so one nests under the other. Content nodes are exempt — they never
+ // become parents, so mutual containment is harmless there.
+ const mutuallyContained =
+ node.type === "group" && isContainedIn(group, node)
+ return !mutuallyContained || group.id > node.id
+ })
+ if (containing.length === 0) return undefined
+ // Equal-area ties break on the lower id so ownership is a property of the
+ // canvas content, not of JSON array order.
+ return containing.reduce((smallest, candidate) => {
+ const candidateArea = candidate.width * candidate.height
+ const smallestArea = smallest.width * smallest.height
+ if (candidateArea < smallestArea) return candidate
+ if (candidateArea === smallestArea && candidate.id < smallest.id)
+ return candidate
+ return smallest
+ })
+}
+
+/** Spatial reading order: top-to-bottom, then left-to-right. */
+const byReadingOrder = (a: CanvasNode, b: CanvasNode): number =>
+ a.y - b.y || a.x - b.x
+
+/** Display name for the edge list: first line of a text node, filename of a
+ * file node, a group's label, a link's url. */
+const displayName = (node: CanvasNode): string => {
+ if (node.type === "text") {
+ const firstLine = (node.text ?? "")
+ .split("\n")
+ .map((line) => line.trim())
+ .find((line) => line.length > 0)
+ // Text nodes often open with a markdown heading — "# Title" reads as
+ // "Title" in an edge list. Only ATX headings (hashes + whitespace) are
+ // stripped; an Obsidian tag like "#project" keeps its hash.
+ return firstLine ? firstLine.replace(/^#+\s+/, "") : "(empty text node)"
+ }
+ if (node.type === "file") {
+ return node.file ? posix.basename(node.file) : "(file node)"
+ }
+ if (node.type === "link") return node.url ?? "(link node)"
+ if (node.type === "group") return node.label ?? "(unlabeled group)"
+ return `(${node.type} node)`
+}
+
+/** One node's rendition: a `[type]` marker, then its content. */
+const renderNode = (node: CanvasNode): string => {
+ if (node.type === "text") return `[text]\n${node.text ?? ""}`
+ if (node.type === "file") {
+ const subpath = node.subpath ?? ""
+ return `[file] → ${node.file ?? "(missing file path)"}${subpath}`
+ }
+ if (node.type === "link") return `[link] → ${node.url ?? "(missing url)"}`
+ return `[${node.type}]`
+}
+
+/** Renders a group section: heading, member nodes in reading order, then
+ * child groups recursively one heading level deeper (capped at H6). */
+const renderGroup = (
+ group: CanvasNode,
+ depth: number,
+ membersByGroupId: ReadonlyMap,
+ childGroupsByParentId: ReadonlyMap,
+): string => {
+ const heading = "#".repeat(Math.min(2 + depth, 6))
+ const members = membersByGroupId.get(group.id) ?? []
+ const childGroups = childGroupsByParentId.get(group.id) ?? []
+ return [
+ `${heading} Group: ${group.label ?? "(unlabeled)"}`,
+ ...members.map(renderNode),
+ ...childGroups.map((child) =>
+ renderGroup(child, depth + 1, membersByGroupId, childGroupsByParentId),
+ ),
+ ].join("\n\n")
+}
+
+/** Groups a list by a key function, preserving each bucket's insertion order. */
+const groupBy = (
+ items: readonly Item[],
+ keyOf: (item: Item) => Key,
+): Map => {
+ // Plain loop with bucket mutation: building a multi-bucket Map immutably
+ // would re-spread every bucket per item for no readability gain.
+ const buckets = new Map()
+ for (const item of items) {
+ const key = keyOf(item)
+ const bucket = buckets.get(key)
+ if (bucket) {
+ bucket.push(item)
+ continue
+ }
+ buckets.set(key, [item])
+ }
+ return buckets
+}
+
+/**
+ * Linearizes .canvas JSON into readable markdown: an overview line, ungrouped
+ * node content first, each group's content under a `Group:` heading (nested
+ * groups one level deeper), then a `Connections` edge list in `A → B (label)`
+ * form with ids resolved to display names. Throws only on unparseable JSON.
+ */
+export const linearizeCanvas = (canvasJson: string): string => {
+ const parsed = ((): unknown => {
+ try {
+ return JSON.parse(canvasJson)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ throw new Error(`invalid .canvas JSON: ${message}`, { cause: error })
+ }
+ })()
+
+ const rawNodes =
+ isRecord(parsed) && Array.isArray(parsed.nodes) ? parsed.nodes : []
+ const rawEdges =
+ isRecord(parsed) && Array.isArray(parsed.edges) ? parsed.edges : []
+ const nodes = rawNodes
+ .map(parseNode)
+ .filter((node) => node !== null)
+ .sort(byReadingOrder)
+ const edges = rawEdges.map(parseEdge).filter((edge) => edge !== null)
+
+ const groups = nodes.filter((node) => node.type === "group")
+ const contentNodes = nodes.filter((node) => node.type !== "group")
+ const membersByGroupId = groupBy(
+ contentNodes,
+ (node) => smallestContainingGroup(node, groups)?.id,
+ )
+ const childGroupsByParentId = groupBy(
+ groups,
+ (group) => smallestContainingGroup(group, groups)?.id,
+ )
+
+ const nodeById = new Map(nodes.map((node) => [node.id, node]))
+ const edgeEndpointName = (id: string): string => {
+ const node = nodeById.get(id)
+ return node ? displayName(node) : `(missing node "${id}")`
+ }
+
+ const ungroupedNodes = membersByGroupId.get(undefined) ?? []
+ const topLevelGroups = childGroupsByParentId.get(undefined) ?? []
+ const edgeLines = edges.map((edge) => {
+ const label = edge.label ? ` (${edge.label})` : ""
+ return `${edgeEndpointName(edge.fromNode)} → ${edgeEndpointName(edge.toNode)}${label}`
+ })
+
+ const sections = [
+ `# Canvas: ${nodes.length} ${nodes.length === 1 ? "node" : "nodes"}, ${edges.length} ${edges.length === 1 ? "edge" : "edges"}`,
+ ...ungroupedNodes.map(renderNode),
+ ...topLevelGroups.map((group) =>
+ renderGroup(group, 0, membersByGroupId, childGroupsByParentId),
+ ),
+ ...(edgeLines.length > 0
+ ? [["## Connections", ...edgeLines].join("\n\n")]
+ : []),
+ ]
+ return sections.join("\n\n")
+}
diff --git a/src/vault-mcp/obsidian-markdown/links.ts b/src/vault-mcp/obsidian-markdown/links.ts
index c1830d31..d9d5d74a 100644
--- a/src/vault-mcp/obsidian-markdown/links.ts
+++ b/src/vault-mcp/obsidian-markdown/links.ts
@@ -325,6 +325,17 @@ const stripExtension = (filePath: string): string => {
return filePath.slice(0, filePath.length - (fileName.length - dotIndex))
}
+/** Returns the file extension including its dot ("photo.png" → ".png"), or ""
+ * when the filename has none. Mirrors stripExtension's semantics exactly —
+ * last dot in the filename (not the path), leading-dot files (".hidden") are
+ * extensionless — so the two can never disagree about where a name splits. */
+const getExtension = (filePath: string): string => {
+ const fileName = posix.basename(filePath)
+ const dotIndex = fileName.lastIndexOf(".")
+ if (dotIndex <= 0) return ""
+ return fileName.slice(dotIndex)
+}
+
/** Picks the winner among same-tier resolution matches: the shortest path,
* with a lexicographic tiebreak for determinism — mirroring the SQL
* resolver's ORDER BY length(path), path LIMIT 1. */
@@ -433,5 +444,6 @@ export const links = {
extractAll,
resolve,
stripExtension,
+ getExtension,
resolveAsset,
}
diff --git a/src/vault-mcp/search/__tests__/file-watcher.test.ts b/src/vault-mcp/search/__tests__/file-watcher.test.ts
index 79cd7e1c..4fdd38f6 100644
--- a/src/vault-mcp/search/__tests__/file-watcher.test.ts
+++ b/src/vault-mcp/search/__tests__/file-watcher.test.ts
@@ -21,7 +21,7 @@ import { join, resolve } from "node:path"
import { tmpdir } from "node:os"
import { watch } from "chokidar"
import { createSearchIndex } from "../search-index.js"
-import { readdirOrNull } from "../../../utils/fs.js"
+import { readdirOrNull, statOrNull } from "../../../utils/fs.js"
import type { SearchIndex } from "../search-index.js"
import { startFileWatcher } from "../file-watcher.js"
import { logger } from "../../../logger.js"
@@ -140,6 +140,52 @@ describe("file-watcher", () => {
expect(jsonResults).toHaveLength(0)
})
+ it(
+ "skips a non-md file that vanishes before its stat",
+ { timeout: 15000 },
+ async () => {
+ const upsertNonMdFileSpy = vi.spyOn(index, "upsertNonMdFile")
+ // Force the stat miss by path — a deterministic stand-in for the file
+ // being deleted between the watcher event and the handler's stat.
+ const actualFsUtils = await vi.importActual<
+ typeof import("../../../utils/fs.js")
+ >("../../../utils/fs.js")
+ vi.mocked(statOrNull).mockImplementation(async (statPath) =>
+ statPath.endsWith("vanished.png")
+ ? null
+ : actualFsUtils.statOrNull(statPath),
+ )
+ onTestFinished(() => {
+ vi.mocked(statOrNull).mockRestore()
+ })
+
+ await startFileWatcher(vault, index, {
+ stabilityThreshold: 200,
+ pollInterval: 50,
+ })
+
+ await writeFile(join(vault, "vanished.png"), "gone", "utf8")
+ await writeFile(join(vault, "kept.png"), "here", "utf8")
+
+ // Both non-md events have been processed once each file's stat ran.
+ await waitFor(() =>
+ ["vanished.png", "kept.png"].every((fileName) =>
+ vi
+ .mocked(statOrNull)
+ .mock.calls.some(([statPath]) => statPath.endsWith(fileName)),
+ ),
+ )
+
+ // Only the surviving file is indexed — the vanished one's early return
+ // must not upsert (without the guard, reading .size off null would throw
+ // inside the watcher callback).
+ await waitFor(() => upsertNonMdFileSpy.mock.calls.length > 0)
+ expect(upsertNonMdFileSpy.mock.calls.map(([path]) => path)).toEqual([
+ "kept.png",
+ ])
+ },
+ )
+
it("ignores hidden directories", { timeout: 15000 }, async () => {
await mkdir(join(vault, ".obsidian"), { recursive: true })
diff --git a/src/vault-mcp/search/__tests__/search-index.test.ts b/src/vault-mcp/search/__tests__/search-index.test.ts
index 5e921e33..30e44ed2 100644
--- a/src/vault-mcp/search/__tests__/search-index.test.ts
+++ b/src/vault-mcp/search/__tests__/search-index.test.ts
@@ -181,6 +181,43 @@ describe("leading callout", () => {
expect(results[0]?.leading_callout?.title).toBe("Scope of this file")
expect(results[0]?.bytes).toBe(100)
})
+
+ it("adds the bytes column to a pre-existing non_md_files table (warm-DB migration)", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "warm-db-"))
+ onTestFinished(() => rm(dir, { recursive: true }))
+ const dbPath = join(dir, "search.db")
+ // Simulate a database file created before the non_md_files bytes column.
+ const legacyDb = new Database(dbPath)
+ legacyDb.exec(`
+ CREATE TABLE non_md_files (
+ path TEXT PRIMARY KEY, base_path TEXT NOT NULL, basename TEXT NOT NULL
+ );
+ `)
+ legacyDb.close()
+
+ // Opening through the factory must add the missing column — the 4-column
+ // upsert would throw against the legacy 3-column table otherwise.
+ const warmIndex = createSearchIndex(dbPath)
+ expect(() => warmIndex.upsertNonMdFile("photo.png", 77)).not.toThrow()
+ warmIndex.upsertNote(
+ {
+ filePath: "source.md",
+ rawContent: "![[photo.png]]",
+ fileStat: testStat(1000),
+ },
+ logger,
+ )
+ expect(warmIndex.getOutgoingLinks({ path: "source.md" }, logger)).toEqual([
+ {
+ path: "photo.png",
+ title: null,
+ exists: true,
+ kind: "asset",
+ bytes: 77,
+ daily_note_forward_ref: false,
+ },
+ ])
+ })
})
describe("bytes", () => {
@@ -1514,7 +1551,7 @@ describe("rebuildFromVault", () => {
title: null,
exists: true,
kind: "asset",
- bytes: null,
+ bytes: 9,
daily_note_forward_ref: false,
},
])
@@ -2378,8 +2415,8 @@ describe("brokenLinkCount", () => {
})
it("does not count wikilinks to non-note assets as broken when files are registered", () => {
- index.upsertNonMdFile("photo.png")
- index.upsertNonMdFile("report.pdf")
+ index.upsertNonMdFile("photo.png", 100)
+ index.upsertNonMdFile("report.pdf", 100)
index.upsertNote(
{
filePath: "source.md",
@@ -2404,7 +2441,7 @@ describe("brokenLinkCount", () => {
})
it("excludes extensionless targets after upsertNonMdFile registers the file", () => {
- index.upsertNonMdFile("Trip Route.canvas")
+ index.upsertNonMdFile("Trip Route.canvas", 100)
index.upsertNote(
{
filePath: "source.md",
@@ -2435,7 +2472,7 @@ describe("brokenLinkCount", () => {
)
expect(index.brokenLinkCount({}, logger).count).toBe(1)
- index.upsertNonMdFile("Route.canvas")
+ index.upsertNonMdFile("Route.canvas", 100)
expect(index.brokenLinkCount({}, logger).count).toBe(0)
const outgoing = index.getOutgoingLinks({ path: "source.md" }, logger)
expect(outgoing).toHaveLength(1)
@@ -2445,7 +2482,7 @@ describe("brokenLinkCount", () => {
})
it("removeNonMdFile makes previously resolved asset links broken again", () => {
- index.upsertNonMdFile("Route.canvas")
+ index.upsertNonMdFile("Route.canvas", 100)
index.upsertNote(
{
filePath: "source.md",
@@ -2539,7 +2576,7 @@ describe("brokenLinkCount", () => {
describe("markdown-style links to non-md targets", () => {
it("resolves a markdown image embed as an asset", () => {
- index.upsertNonMdFile("pics/photo.png")
+ index.upsertNonMdFile("pics/photo.png", 100)
index.upsertNote(
{
filePath: "source.md",
@@ -2566,7 +2603,7 @@ describe("markdown-style links to non-md targets", () => {
title: null,
exists: true,
kind: "asset",
- bytes: null,
+ bytes: 100,
daily_note_forward_ref: false,
},
])
@@ -2574,7 +2611,7 @@ describe("markdown-style links to non-md targets", () => {
})
it("resolves a markdown link to a PDF as an asset", () => {
- index.upsertNonMdFile("papers/report.pdf")
+ index.upsertNonMdFile("papers/report.pdf", 100)
index.upsertNote(
{
filePath: "source.md",
@@ -2589,14 +2626,14 @@ describe("markdown-style links to non-md targets", () => {
title: null,
exists: true,
kind: "asset",
- bytes: null,
+ bytes: 100,
daily_note_forward_ref: false,
},
])
})
it("percent-decodes a markdown asset path with folders and spaces", () => {
- index.upsertNonMdFile("Trip Photos/pic 1.png")
+ index.upsertNonMdFile("Trip Photos/pic 1.png", 100)
index.upsertNote(
{
filePath: "source.md",
@@ -2611,7 +2648,7 @@ describe("markdown-style links to non-md targets", () => {
title: null,
exists: true,
kind: "asset",
- bytes: null,
+ bytes: 100,
daily_note_forward_ref: false,
},
])
@@ -2755,7 +2792,7 @@ describe("markdown-style links to non-md targets", () => {
describe("asset targets written with extensions", () => {
it("resolves a wikilink embed by basename when the asset lives in a subfolder", () => {
- index.upsertNonMdFile("attachments/photo.png")
+ index.upsertNonMdFile("attachments/photo.png", 100)
index.upsertNote(
{
filePath: "source.md",
@@ -2770,7 +2807,7 @@ describe("asset targets written with extensions", () => {
title: null,
exists: true,
kind: "asset",
- bytes: null,
+ bytes: 100,
daily_note_forward_ref: false,
},
])
@@ -2778,7 +2815,7 @@ describe("asset targets written with extensions", () => {
})
it("resolves a markdown embed by basename when the asset lives in a subfolder", () => {
- index.upsertNonMdFile("attachments/photo.png")
+ index.upsertNonMdFile("attachments/photo.png", 100)
index.upsertNote(
{
filePath: "source.md",
@@ -2793,7 +2830,7 @@ describe("asset targets written with extensions", () => {
title: null,
exists: true,
kind: "asset",
- bytes: null,
+ bytes: 100,
daily_note_forward_ref: false,
},
])
@@ -2801,7 +2838,7 @@ describe("asset targets written with extensions", () => {
})
it("resolves a relative asset link against the source note's folder", () => {
- index.upsertNonMdFile("assets/photo.png")
+ index.upsertNonMdFile("assets/photo.png", 100)
index.upsertNote(
{
filePath: "A/note.md",
@@ -2816,7 +2853,7 @@ describe("asset targets written with extensions", () => {
title: null,
exists: true,
kind: "asset",
- bytes: null,
+ bytes: 100,
daily_note_forward_ref: false,
},
])
@@ -2824,7 +2861,7 @@ describe("asset targets written with extensions", () => {
})
it("resolves a folder-qualified target with extension by path suffix", () => {
- index.upsertNonMdFile("deep/sub/photo.png")
+ index.upsertNonMdFile("deep/sub/photo.png", 100)
index.upsertNote(
{
filePath: "source.md",
@@ -2839,7 +2876,7 @@ describe("asset targets written with extensions", () => {
title: null,
exists: true,
kind: "asset",
- bytes: null,
+ bytes: 100,
daily_note_forward_ref: false,
},
])
@@ -2847,8 +2884,8 @@ describe("asset targets written with extensions", () => {
})
it("resolves a shared basename deterministically to the shortest path", () => {
- index.upsertNonMdFile("bb/photo.png")
- index.upsertNonMdFile("a/photo.png")
+ index.upsertNonMdFile("bb/photo.png", 100)
+ index.upsertNonMdFile("a/photo.png", 100)
index.upsertNote(
{
filePath: "source.md",
@@ -2863,7 +2900,7 @@ describe("asset targets written with extensions", () => {
title: null,
exists: true,
kind: "asset",
- bytes: null,
+ bytes: 100,
daily_note_forward_ref: false,
},
])
@@ -2873,8 +2910,8 @@ describe("asset targets written with extensions", () => {
// "photo.png.canvas" strips to base_path "photo.png" — the same text as
// the target — so the stem tiers would hit it. The full-filename family
// must win: the target names an actual .png that exists elsewhere.
- index.upsertNonMdFile("photo.png.canvas")
- index.upsertNonMdFile("a/photo.png")
+ index.upsertNonMdFile("photo.png.canvas", 100)
+ index.upsertNonMdFile("a/photo.png", 100)
index.upsertNote(
{
filePath: "source.md",
@@ -2889,7 +2926,7 @@ describe("asset targets written with extensions", () => {
title: null,
exists: true,
kind: "asset",
- bytes: null,
+ bytes: 100,
daily_note_forward_ref: false,
},
])
@@ -2899,7 +2936,7 @@ describe("asset targets written with extensions", () => {
// With only photo.png.canvas in the vault, [[photo.png]] still resolves
// via its stem — the same matching that gives [[Trip Route]] →
// Trip Route.canvas. The stem tiers are a fallback, not dead code.
- index.upsertNonMdFile("photo.png.canvas")
+ index.upsertNonMdFile("photo.png.canvas", 100)
index.upsertNote(
{
filePath: "source.md",
@@ -2914,14 +2951,14 @@ describe("asset targets written with extensions", () => {
title: null,
exists: true,
kind: "asset",
- bytes: null,
+ bytes: 100,
daily_note_forward_ref: false,
},
])
})
it("does not resolve a basename whose extension differs from the file's", () => {
- index.upsertNonMdFile("attachments/photo.jpg")
+ index.upsertNonMdFile("attachments/photo.jpg", 100)
index.upsertNote(
{
filePath: "source.md",
@@ -2956,14 +2993,14 @@ describe("asset targets written with extensions", () => {
)
expect(index.brokenLinkCount({}, logger).count).toBe(1)
- index.upsertNonMdFile("attachments/photo.png")
+ index.upsertNonMdFile("attachments/photo.png", 100)
expect(index.getOutgoingLinks({ path: "source.md" }, logger)).toEqual([
{
path: "attachments/photo.png",
title: null,
exists: true,
kind: "asset",
- bytes: null,
+ bytes: 100,
daily_note_forward_ref: false,
},
])
@@ -2973,7 +3010,7 @@ describe("asset targets written with extensions", () => {
it("does not let LIKE wildcards in the target match unrelated files via full-path suffix", () => {
// Only photo1final.png exists — if the _ in the target were treated as a
// LIKE wildcard it would match (1 satisfies _), giving a false resolution.
- index.upsertNonMdFile("img/photo1final.png")
+ index.upsertNonMdFile("img/photo1final.png", 100)
index.upsertNote(
{
filePath: "source.md",
diff --git a/src/vault-mcp/search/file-watcher.ts b/src/vault-mcp/search/file-watcher.ts
index 3a89c0ab..166b72c4 100644
--- a/src/vault-mcp/search/file-watcher.ts
+++ b/src/vault-mcp/search/file-watcher.ts
@@ -9,7 +9,7 @@ import { join, relative, resolve as resolvePath } from "node:path"
import type { SearchIndex } from "./search-index.js"
import { logger } from "../../logger.js"
import { describeError } from "../../utils/describe-error.js"
-import { readdirOrNull } from "../../utils/fs.js"
+import { readdirOrNull, statOrNull } from "../../utils/fs.js"
import { isErrnoException } from "../../utils/is-errno-exception.js"
/** ms between filesystem polls when usePolling is on. chokidar's raw default is
@@ -77,7 +77,11 @@ export const startFileWatcher = (
const relativePath = relative(vaultPath, filePath)
if (!filePath.endsWith(".md")) {
- search.upsertNonMdFile(relativePath)
+ const fileStat = await statOrNull(filePath)
+ // Vanished between the watcher event and the stat — the unlink event
+ // that follows will remove any existing row.
+ if (!fileStat) return
+ search.upsertNonMdFile(relativePath, fileStat.size)
logger.debug("indexed non-md file", { path: relativePath })
return
}
diff --git a/src/vault-mcp/search/search-index.ts b/src/vault-mcp/search/search-index.ts
index 3039d74c..09f94fa7 100644
--- a/src/vault-mcp/search/search-index.ts
+++ b/src/vault-mcp/search/search-index.ts
@@ -2,6 +2,7 @@ import Database from "better-sqlite3"
import { DateTime } from "luxon"
import * as sqliteVec from "sqlite-vec"
import { readFile, readdir, stat } from "node:fs/promises"
+import type { Dirent } from "node:fs"
import { join, basename, posix, relative, resolve } from "node:path"
import type { Logger } from "../../logger.js"
import { parseNote } from "../obsidian-markdown/frontmatter.js"
@@ -21,6 +22,7 @@ import type { Reranker } from "./reranker.js"
import { chunkNoteContent } from "./chunker.js"
import { describeError } from "../../utils/describe-error.js"
import { filterValidSymlinks } from "../../utils/filter-valid-symlinks.js"
+import { statOrNull } from "../../utils/fs.js"
import {
isString,
coerceToArray,
@@ -341,7 +343,8 @@ export const createSearchIndex = (
CREATE TABLE IF NOT EXISTS non_md_files (
path TEXT PRIMARY KEY,
base_path TEXT NOT NULL,
- basename TEXT NOT NULL
+ basename TEXT NOT NULL,
+ bytes INTEGER
);
CREATE INDEX IF NOT EXISTS idx_non_md_base_path ON non_md_files(base_path);
CREATE INDEX IF NOT EXISTS idx_non_md_basename ON non_md_files(basename);
@@ -454,6 +457,16 @@ export const createSearchIndex = (
db.exec(`ALTER TABLE notes ADD COLUMN kanban_done_lanes TEXT`)
}
+ // Same idempotent migration for non_md_files.bytes: a warm database from
+ // before the column existed would fail the upsert. Nullable — NULL means
+ // "not yet statted"; the startup rebuild backfills every row.
+ const nonMdColumns = db
+ .prepare(`PRAGMA table_info(non_md_files)`)
+ .all()
+ if (!nonMdColumns.some((column) => column.name === "bytes")) {
+ db.exec(`ALTER TABLE non_md_files ADD COLUMN bytes INTEGER`)
+ }
+
// Daily notes folder for forward-ref exclusion — broken links under
// this folder are treated as intentional "create on click" navigation.
// Set via setDailyNotesFolder from server.ts config; null until then.
@@ -510,7 +523,7 @@ export const createSearchIndex = (
// incrementally by the file watcher.
const upsertNonMdFileStmt = db.prepare(
- `INSERT OR REPLACE INTO non_md_files (path, base_path, basename) VALUES (?, ?, ?)`,
+ `INSERT OR REPLACE INTO non_md_files (path, base_path, basename, bytes) VALUES (?, ?, ?, ?)`,
)
const deleteNonMdFileStmt = db.prepare(
`DELETE FROM non_md_files WHERE path = ?`,
@@ -793,45 +806,33 @@ export const createSearchIndex = (
return basenameMatch?.path ?? null
}
- /** Indexes non-markdown files from a directory listing into the
- * non_md_files table. Skips hidden directories (same filter as notes). */
+ /** Indexes pre-statted non-markdown files into the non_md_files table.
+ * The caller owns entry filtering and stat (fs work stays out of the
+ * write transaction); this just writes the rows. */
const indexNonMarkdownFiles = (
- entries: ReadonlyArray<{
- isFile: () => boolean
- isSymbolicLink: () => boolean
- name: string
- parentPath: string
- }>,
- normalizedVault: string,
+ files: ReadonlyArray<{ relativePath: string; bytes: number }>,
): number => {
- // Counter incremented per non-md file upserted — returned to caller for logging
- let filesIndexed = 0
- for (const directoryEntry of entries) {
- if (
- (!directoryEntry.isFile() && !directoryEntry.isSymbolicLink()) ||
- directoryEntry.name.endsWith(".md")
+ for (const file of files) {
+ const basePath = links.stripExtension(file.relativePath)
+ const baseFilename = links.stripExtension(basename(file.relativePath))
+ upsertNonMdFileStmt.run(
+ file.relativePath,
+ basePath,
+ baseFilename,
+ file.bytes,
)
- continue
- const absolutePath = join(directoryEntry.parentPath, directoryEntry.name)
- const relativePath = relative(normalizedVault, absolutePath)
- if (relativePath.split("/").some((segment) => segment.startsWith(".")))
- continue
- const basePath = links.stripExtension(relativePath)
- const baseFilename = links.stripExtension(directoryEntry.name)
- upsertNonMdFileStmt.run(relativePath, basePath, baseFilename)
- filesIndexed += 1
}
- return filesIndexed
+ return files.length
}
/** Adds a single non-markdown file to the index and re-resolves any
* unresolved links that now match it. Called by the file watcher on
* add/change. Mirrors the note forward-reference re-resolution pattern:
* updates the link target from the raw text to the resolved non-md path. */
- const upsertNonMdFile = (filePath: string): void => {
+ const upsertNonMdFile = (filePath: string, bytes: number): void => {
const basePath = links.stripExtension(filePath)
const baseFilename = links.stripExtension(basename(filePath))
- upsertNonMdFileStmt.run(filePath, basePath, baseFilename)
+ upsertNonMdFileStmt.run(filePath, basePath, baseFilename, bytes)
// Re-resolve unresolved links that now match this non-md file — upgrade
// raw targets (e.g. "Trip Route") to resolved paths ("Trip Route.canvas").
@@ -1364,21 +1365,51 @@ export const createSearchIndex = (
logger,
})
- // Filter directory entries to visible .md files, then load their content
- const markdownFiles = entries.reduce<
- { relativePath: string; absolutePath: string }[]
- >((filteredFiles, directoryEntry) => {
- if (
- (!directoryEntry.isFile() && !directoryEntry.isSymbolicLink()) ||
- !directoryEntry.name.endsWith(".md")
+ // Filter directory entries to visible files of one kind (.md notes or
+ // non-md assets) — shared by the notes pass and the non-md stat pass.
+ // Named stages keep each pass O(n) (a spread-accumulating reduce would
+ // re-copy the array per entry) and let the chain read top-to-bottom.
+ const visibleFilesOfKind = (
+ fileKind: "note" | "asset",
+ ): { relativePath: string; absolutePath: string }[] => {
+ const matchesKind = (directoryEntry: Dirent): boolean => {
+ if (!directoryEntry.isFile() && !directoryEntry.isSymbolicLink())
+ return false
+ const isNoteFile = directoryEntry.name.endsWith(".md")
+ return fileKind === "note" ? isNoteFile : !isNoteFile
+ }
+ const toFilePaths = (
+ directoryEntry: Dirent,
+ ): { relativePath: string; absolutePath: string } => {
+ const absolutePath = join(
+ directoryEntry.parentPath,
+ directoryEntry.name,
+ )
+ return {
+ relativePath: relative(normalizedVault, absolutePath),
+ absolutePath,
+ }
+ }
+ const isVisiblePath = (file: { relativePath: string }): boolean =>
+ !file.relativePath.split("/").some((segment) => segment.startsWith("."))
+
+ return entries.filter(matchesKind).map(toFilePaths).filter(isVisiblePath)
+ }
+
+ const markdownFiles = visibleFilesOfKind("note")
+
+ // Stat non-md files before the write transaction (fs stays out of it).
+ // A file vanishing between listing and stat (sync race) is dropped here
+ // and re-indexed by its own watcher event.
+ const nonMarkdownFileSizes = (
+ await Promise.all(
+ visibleFilesOfKind("asset").map(async (file) => {
+ const fileStat = await statOrNull(file.absolutePath)
+ if (!fileStat) return null
+ return { relativePath: file.relativePath, bytes: fileStat.size }
+ }),
)
- return filteredFiles
- const absolutePath = join(directoryEntry.parentPath, directoryEntry.name)
- const relativePath = relative(normalizedVault, absolutePath)
- if (relativePath.split("/").some((segment) => segment.startsWith(".")))
- return filteredFiles
- return [...filteredFiles, { relativePath, absolutePath }]
- }, [])
+ ).filter((entry) => entry !== null)
const noteContents = await Promise.all(
markdownFiles.map(async (file) => {
@@ -1399,7 +1430,7 @@ export const createSearchIndex = (
db.transaction(() => {
// Index non-markdown files so extensionless wikilinks to .canvas, .base,
// etc. are recognized as asset references rather than broken note links.
- const nonMdCount = indexNonMarkdownFiles(entries, normalizedVault)
+ const nonMdCount = indexNonMarkdownFiles(nonMarkdownFileSizes)
logger.debug("indexed non-md files", { count: nonMdCount })
// Pass 1: index all notes (content, frontmatter, FTS) — skip link
diff --git a/src/vault-mcp/search/search-queries.ts b/src/vault-mcp/search/search-queries.ts
index 500fa40e..1b616587 100644
--- a/src/vault-mcp/search/search-queries.ts
+++ b/src/vault-mcp/search/search-queries.ts
@@ -1567,7 +1567,7 @@ export const getOutgoingLinks = (
CASE WHEN n.path IS NOT NULL THEN 'note'
WHEN f.path IS NOT NULL THEN 'asset'
ELSE 'note' END as kind,
- n.bytes
+ COALESCE(n.bytes, f.bytes) as bytes
FROM links l
LEFT JOIN notes n ON n.path = l.target
LEFT JOIN non_md_files f ON f.path = l.target
diff --git a/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts b/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts
index 1da71e40..53be9bbf 100644
--- a/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts
+++ b/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts
@@ -38,6 +38,8 @@ const {
deleteNote,
listNotes,
listAssets,
+ readAsset,
+ statAssets,
} = vaultFs
let vault: string
@@ -835,6 +837,15 @@ describe("listAssets", () => {
])
})
+ it("scopes the listing to a folder, excluding assets outside it", async () => {
+ await writeFile(join(vault, "outside.txt"), "not in assets/", "utf8")
+ const files = await listAssets(
+ { vaultPath: vault, folder: "assets" },
+ logger,
+ )
+ expect(files).toEqual(["assets/board.canvas", "assets/photo.png"])
+ })
+
it("includes a symlinked asset in the listing", async () => {
await symlink("assets/photo.png", join(vault, "linked.png"))
const files = await listAssets({ vaultPath: vault }, logger)
@@ -1596,3 +1607,77 @@ describe("concurrent writes (exclusive lock)", () => {
).toBe("second life\n")
})
})
+
+describe("readAsset", () => {
+ it("reads a binary file whole with its byte count and lowercased extension", async () => {
+ const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff])
+ await writeFile(join(vault, "photo.PNG"), bytes)
+ const asset = await readAsset(
+ { vaultPath: vault, path: "photo.PNG", maxBytes: 1024 },
+ logger,
+ )
+ expect(asset.buffer.equals(bytes)).toBe(true)
+ expect(asset.bytes).toBe(6)
+ expect(asset.extension).toBe(".png")
+ })
+
+ it("rejects a .md path as not an asset", async () => {
+ await writeFile(join(vault, "note.md"), "# note", "utf8")
+ await expect(
+ readAsset({ vaultPath: vault, path: "note.md", maxBytes: 1024 }, logger),
+ ).rejects.toThrow('not an asset: "note.md" is a markdown note')
+ })
+
+ it("blocks path traversal out of the vault", async () => {
+ await expect(
+ readAsset(
+ { vaultPath: vault, path: "../outside.png", maxBytes: 1024 },
+ logger,
+ ),
+ ).rejects.toThrow(
+ 'path traversal blocked: "../outside.png" escapes vault root',
+ )
+ })
+
+ it("rejects a missing file as asset not found", async () => {
+ await expect(
+ readAsset(
+ { vaultPath: vault, path: "ghost.png", maxBytes: 1024 },
+ logger,
+ ),
+ ).rejects.toThrow('asset not found: "ghost.png"')
+ })
+
+ it("rejects a file over the byte cap before reading it", async () => {
+ await writeFile(join(vault, "big.bin"), "12345678901", "utf8")
+ await expect(
+ readAsset({ vaultPath: vault, path: "big.bin", maxBytes: 10 }, logger),
+ ).rejects.toThrow(
+ 'asset too large: "big.bin" is 11 bytes (cap 10 bytes — raise MAX_ASSET_BYTES to read larger files)',
+ )
+ })
+})
+
+describe("statAssets", () => {
+ it("returns each path's byte size in input order", async () => {
+ await writeFile(join(vault, "a.png"), "12345", "utf8")
+ await writeFile(join(vault, "b.canvas"), "12", "utf8")
+ const statted = await statAssets(
+ { vaultPath: vault, paths: ["a.png", "b.canvas"] },
+ logger,
+ )
+ expect(statted).toEqual([
+ { path: "a.png", bytes: 5 },
+ { path: "b.canvas", bytes: 2 },
+ ])
+ })
+
+ it("drops a path that vanished instead of throwing", async () => {
+ await writeFile(join(vault, "kept.png"), "1234", "utf8")
+ const statted = await statAssets(
+ { vaultPath: vault, paths: ["kept.png", "vanished.png"] },
+ logger,
+ )
+ expect(statted).toEqual([{ path: "kept.png", bytes: 4 }])
+ })
+})
diff --git a/src/vault-mcp/vault-operations/asset-operations.ts b/src/vault-mcp/vault-operations/asset-operations.ts
new file mode 100644
index 00000000..044b8b97
--- /dev/null
+++ b/src/vault-mcp/vault-operations/asset-operations.ts
@@ -0,0 +1,229 @@
+import { vaultFs } from "./vault-filesystem.js"
+import { linearizeCanvas } from "../obsidian-markdown/canvas.js"
+import { links } from "../obsidian-markdown/links.js"
+import { fitImageToByteBudget } from "../../utils/fit-image-to-byte-budget.js"
+import type { FittedImage } from "../../utils/fit-image-to-byte-budget.js"
+import type { Logger } from "../../logger.js"
+
+/**
+ * Asset operations use-case — the grouped read/browse surface over the
+ * vault's non-markdown files, composing vaultFs primitives with the parsers
+ * and the image pipeline. Two operations share the domain:
+ *
+ * - `readAssetContent` dispatches one file to its most useful representation:
+ * images fitted to a byte budget, canvases linearized (or their raw JSON
+ * source), text formats decoded verbatim, structured errors for the rest.
+ * - `buildAssetListing` browses many: extension-filtered, counted per
+ * extension over the full filtered set, and capped to a statted slice.
+ * There is no pagination — `limit` caps the returned entries, and
+ * counts/total over the full set tell the caller to narrow with
+ * folder/extensions when the cap is hit.
+ *
+ * The tool layer maps results to MCP content blocks / wire JSON.
+ */
+
+// ── Reading ─────────────────────────────────────────────────────
+
+/** Extensions dispatched to the image pipeline (model-visible image blocks). */
+const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"])
+
+/** Extensions returned verbatim as text — plain-text formats an agent can
+ * read directly. .svg is XML source; .base is Obsidian Bases YAML. */
+const TEXT_PASSTHROUGH_EXTENSIONS = new Set([
+ ".svg",
+ ".json",
+ ".txt",
+ ".csv",
+ ".xml",
+ ".log",
+ ".base",
+])
+
+/** Fixed cap on text output (passthrough files and canvas renditions) so a
+ * huge text asset can't blow a client's response limit. Deliberately not an
+ * env var — the image budget is the tunable surface; text past this size
+ * needs paging, not a bigger blob. */
+const MAX_TEXT_OUTPUT_BYTES = 102_400
+
+/** The computed result of one asset read, before content-block formatting:
+ * an image (fitted to the byte budget) or a text rendition. */
+export type AssetReadResult =
+ | Readonly<{
+ kind: "image"
+ fitted: FittedImage
+ originalBytes: number
+ path: string
+ }>
+ | Readonly<{ kind: "text"; text: string }>
+
+/** Rejects text output past the fixed cap — an explicit error beats silent
+ * truncation, and states the actual size so the caller knows what exists. */
+const assertTextWithinCap = (params: { text: string; path: string }): void => {
+ const textBytes = Buffer.byteLength(params.text, "utf8")
+ if (textBytes <= MAX_TEXT_OUTPUT_BYTES) return
+ throw new Error(
+ `text output too large: "${params.path}" renders to ${textBytes} bytes ` +
+ `(cap ${MAX_TEXT_OUTPUT_BYTES} bytes)`,
+ )
+}
+
+/** Decodes an asset buffer as UTF-8, rejecting invalid byte sequences — text
+ * is promised verbatim, and the default decoder would silently substitute
+ * U+FFFD for every undecodable byte instead. */
+const decodeUtf8Strict = (params: { buffer: Buffer; path: string }): string => {
+ try {
+ return new TextDecoder("utf-8", { fatal: true }).decode(params.buffer)
+ } catch (error) {
+ throw new Error(
+ `not valid UTF-8: "${params.path}" cannot be returned as text`,
+ { cause: error },
+ )
+ }
+}
+
+/**
+ * Reads a non-markdown vault file and returns its most useful representation
+ * per type; `raw` skips the canvas rendition for the exact JSON source.
+ * Throws structured errors for images with `raw`, PDFs, and unsupported
+ * types — each stating the file's existence and size.
+ */
+const readAssetContent = async (
+ params: {
+ vaultPath: string
+ path: string
+ raw?: boolean | undefined
+ maxAssetBytes: number
+ maxImageOutputBytes: number
+ },
+ logger: Logger,
+): Promise => {
+ const { path, raw } = params
+ const asset = await vaultFs.readAsset(
+ { vaultPath: params.vaultPath, path, maxBytes: params.maxAssetBytes },
+ logger,
+ )
+ const isImage = IMAGE_EXTENSIONS.has(asset.extension)
+ if (isImage && raw) {
+ throw new Error(
+ `raw source is not available for images: "${path}" is ` +
+ `binary — its image block is the delivered form`,
+ )
+ }
+ if (isImage) {
+ const fitted = await fitImageToByteBudget({
+ buffer: asset.buffer,
+ budgetBytes: params.maxImageOutputBytes,
+ })
+ return { kind: "image", fitted, originalBytes: asset.bytes, path }
+ }
+ if (asset.extension === ".canvas") {
+ const canvasSource = decodeUtf8Strict({ buffer: asset.buffer, path })
+ const text = raw ? canvasSource : linearizeCanvas(canvasSource)
+ assertTextWithinCap({ text, path })
+ return { kind: "text", text }
+ }
+ if (TEXT_PASSTHROUGH_EXTENSIONS.has(asset.extension)) {
+ const text = decodeUtf8Strict({ buffer: asset.buffer, path })
+ assertTextWithinCap({ text, path })
+ return { kind: "text", text }
+ }
+ if (asset.extension === ".pdf") {
+ throw new Error(
+ `PDF reading is not yet supported: "${path}" exists ` +
+ `(${asset.bytes} bytes) but text extraction is not available yet`,
+ )
+ }
+ throw new Error(
+ `unsupported asset type "${asset.extension}": "${path}" exists ` +
+ `(${asset.bytes} bytes). Readable types: images ` +
+ `(.png/.jpg/.jpeg/.gif/.webp), .canvas, and text formats ` +
+ `(.svg/.json/.txt/.csv/.xml/.log/.base)`,
+ )
+}
+
+// ── Browsing ────────────────────────────────────────────────────
+
+/** Display extension for listings: lowercased with its dot, or "(none)" for
+ * extensionless files. */
+const extensionOf = (assetPath: string): string =>
+ links.getExtension(assetPath).toLowerCase() || "(none)"
+
+/** Normalizes a caller-supplied extension filter entry: lowercased, leading
+ * dot ensured — so "PNG", "png", and ".png" all match ".png". */
+const normalizeExtension = (extension: string): string => {
+ const lowered = extension.toLowerCase()
+ return lowered.startsWith(".") ? lowered : `.${lowered}`
+}
+
+export type AssetListing = Readonly<{
+ assets: readonly Readonly<{
+ path: string
+ extension: string
+ bytes: number
+ }>[]
+ extensionCounts: Readonly>
+ total: number
+ truncated: boolean
+}>
+
+/**
+ * Lists a folder's (or the vault's) assets: extension-filtered, counted per
+ * extension over the full filtered set, and capped to `limit` entries with
+ * byte sizes statted for the returned slice only — entries beyond the cap
+ * are never statted.
+ */
+const buildAssetListing = async (
+ params: {
+ vaultPath: string
+ folder?: string | undefined
+ extensions?: readonly string[] | undefined
+ limit: number
+ },
+ logger: Logger,
+): Promise => {
+ const assetPaths = await vaultFs.listAssets(
+ { vaultPath: params.vaultPath, folder: params.folder },
+ logger,
+ )
+ const extensionFilter = params.extensions
+ ? new Set(params.extensions.map(normalizeExtension))
+ : undefined
+ const filteredPaths = extensionFilter
+ ? assetPaths.filter((assetPath) =>
+ extensionFilter.has(links.getExtension(assetPath).toLowerCase()),
+ )
+ : assetPaths
+
+ const filteredExtensions = filteredPaths.map(extensionOf)
+ const extensionCounts = filteredExtensions.reduce>(
+ (counts, extension) => ({
+ ...counts,
+ [extension]: (counts[extension] ?? 0) + 1,
+ }),
+ {},
+ )
+
+ const returnedPaths = filteredPaths.slice(0, params.limit)
+ const stattedAssets = await vaultFs.statAssets(
+ { vaultPath: params.vaultPath, paths: returnedPaths },
+ logger,
+ )
+ return {
+ assets: stattedAssets.map((entry) => ({
+ path: entry.path,
+ extension: extensionOf(entry.path),
+ bytes: entry.bytes,
+ })),
+ extensionCounts,
+ total: filteredPaths.length,
+ truncated: filteredPaths.length > params.limit,
+ }
+}
+
+/** The asset operations surface — one namespace for the grouped read/browse
+ * functionality, matching the folder's operation modules (noteMover,
+ * vaultPatcher, taskUpdater). */
+export const assetOperations = {
+ readAssetContent,
+ buildAssetListing,
+}
diff --git a/src/vault-mcp/vault-operations/vault-filesystem.ts b/src/vault-mcp/vault-operations/vault-filesystem.ts
index 2419cf9b..3ce6fd21 100644
--- a/src/vault-mcp/vault-operations/vault-filesystem.ts
+++ b/src/vault-mcp/vault-operations/vault-filesystem.ts
@@ -2,6 +2,7 @@ import {
writeFile,
readdir,
mkdir,
+ open,
unlink,
rename,
link,
@@ -13,8 +14,16 @@ import { join, dirname, relative, resolve, posix } from "node:path"
import picomatch from "picomatch"
import { describeError } from "../../utils/describe-error.js"
import { filterValidSymlinks } from "../../utils/filter-valid-symlinks.js"
-import { fileExists, readFileOrNull, readdirOrNull } from "../../utils/fs.js"
+import {
+ fileExists,
+ readFileOrNull,
+ readdirOrNull,
+ statOrNull,
+} from "../../utils/fs.js"
+import { isErrnoException } from "../../utils/is-errno-exception.js"
+import { mapWithConcurrency } from "../../utils/map-with-concurrency.js"
import { withExclusiveFileLock } from "../../utils/file-write-lock.js"
+import { links } from "../obsidian-markdown/links.js"
import {
parseNote,
stringifyNote,
@@ -491,24 +500,120 @@ const listNotes = async (
return result
}
-/** Lists non-.md files (attachments — images, canvases, PDFs, …) under the
- * vault root. Same walk and filters as listNotes; moveNote resolves asset
- * links inside a moved note against the result. */
+/** Lists non-.md files (assets — images, canvases, PDFs, …) under a folder
+ * (or the vault root). Same walk and filters as listNotes; moveNote resolves
+ * asset links inside a moved note against the vault-wide result. */
const listAssets = async (
- params: { vaultPath: string },
+ params: { vaultPath: string; folder?: string | undefined },
logger: Logger,
): Promise => {
const paths = await listVaultFilePaths(
{
vaultPath: params.vaultPath,
+ folder: params.folder,
fileKind: "asset",
},
logger,
)
- logger.info("listed assets", { count: paths.length })
+ logger.info("listed assets", { folder: params.folder, count: paths.length })
return paths
}
+/** Reads a non-.md vault file (an asset) as raw bytes, with a size cap.
+ * Markdown notes are rejected — .md reads go through readNote, which treats
+ * files as notes, not bytes. The stat-before-read cap guards memory, and the
+ * read itself goes through a handle with a buffer bounded by that stat plus
+ * one sentinel byte — a file that grows between stat and read (a sync race)
+ * is rejected instead of ballooning memory or serving torn content. */
+const readAsset = async (
+ params: { vaultPath: string; path: string; maxBytes: number },
+ logger: Logger,
+): Promise<{ buffer: Buffer; bytes: number; extension: string }> => {
+ if (params.path.endsWith(".md")) {
+ throw new Error(`not an asset: "${params.path}" is a markdown note`)
+ }
+ const fullPath = resolveSafePath(params.vaultPath, params.path)
+ const fileStats = await statOrNull(fullPath)
+ if (!fileStats || !fileStats.isFile()) {
+ throw new Error(`asset not found: "${params.path}"`)
+ }
+ if (fileStats.size > params.maxBytes) {
+ throw new Error(
+ `asset too large: "${params.path}" is ${fileStats.size} bytes ` +
+ `(cap ${params.maxBytes} bytes — raise MAX_ASSET_BYTES to read larger files)`,
+ )
+ }
+
+ const fileHandle = await (async () => {
+ try {
+ return await open(fullPath, "r")
+ } catch (error) {
+ if (isErrnoException(error, "ENOENT")) {
+ throw new Error(`asset not found: "${params.path}"`, { cause: error })
+ }
+ throw error
+ }
+ })()
+ try {
+ // One sentinel byte past the statted size: if the file grew after the
+ // stat, the sentinel fills and the read is rejected as unstable.
+ const readBuffer = Buffer.alloc(
+ Math.min(fileStats.size, params.maxBytes) + 1,
+ )
+ // Sequential fill loop — a single read() may return short on some
+ // platforms, so accumulate until EOF or the buffer is full.
+ let totalBytesRead = 0
+ while (totalBytesRead < readBuffer.length) {
+ const { bytesRead } = await fileHandle.read(
+ readBuffer,
+ totalBytesRead,
+ readBuffer.length - totalBytesRead,
+ totalBytesRead,
+ )
+ if (bytesRead === 0) break
+ totalBytesRead += bytesRead
+ }
+ if (totalBytesRead === readBuffer.length) {
+ throw new Error(
+ `asset changed while reading: "${params.path}" grew past its ` +
+ `measured size — retry the read`,
+ )
+ }
+ const buffer = readBuffer.subarray(0, totalBytesRead)
+ logger.info("read asset", { path: params.path, bytes: buffer.length })
+ return {
+ buffer,
+ bytes: buffer.length,
+ extension: links.getExtension(params.path).toLowerCase(),
+ }
+ } finally {
+ await fileHandle.close()
+ }
+}
+
+/** Stats a page of asset paths, returning each existing file's byte size.
+ * Assets that vanished between listing and stat (a sync race) are dropped
+ * rather than thrown — the listing is a snapshot, not a lock. */
+const statAssets = async (
+ params: { vaultPath: string; paths: readonly string[] },
+ logger: Logger,
+): Promise<{ path: string; bytes: number }[]> => {
+ const stattedEntries = await mapWithConcurrency({
+ items: params.paths,
+ concurrency: 16,
+ mapper: async (assetPath) => {
+ const fileStats = await statOrNull(
+ resolveSafePath(params.vaultPath, assetPath),
+ )
+ if (!fileStats) return null
+ return { path: assetPath, bytes: fileStats.size }
+ },
+ })
+ const existingEntries = stattedEntries.filter((entry) => entry !== null)
+ logger.info("statted assets", { count: existingEntries.length })
+ return existingEntries
+}
+
export const vaultFs = {
readNote,
readNoteOutline,
@@ -519,4 +624,6 @@ export const vaultFs = {
deleteNote,
listNotes,
listAssets,
+ readAsset,
+ statAssets,
}