From 26301ba437779894bf257b69325953435c5678a8 Mon Sep 17 00:00:00 2001 From: Behzat Can Acele <61169260+bezata@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:10:35 +0300 Subject: [PATCH 1/2] feat(vault): add vault.* namespace for multi-vault discovery + selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four new tools — vault.list, vault.current, vault.select, vault.reset — that let an LLM enumerate the user's known Obsidian vaults and switch between them for the session, without restarting the server or threading vaultPath through every call. Tool surface: 62 → 66 across 15 → 16 namespaces. Fully backwards compatible: - OBSIDIAN_VAULT_PATH behaves identically to v0.2.5. - No existing tool signatures change. - requireVaultPath precedence gains a middle slot — arg > session > env > error — that collapses to v0.2.5's arg > env > error when vault.select is never called. - All 56 pre-existing tests pass unmodified; 55 new tests added. Vault discovery: - OBSIDIAN_VAULT_PATH — the default (documented, stable) - OBSIDIAN_VAULT_=path env vars — additional named vaults (new, documented, stable) - obsidian.json parsing — opt-out via KOBSIDIAN_VAULT_DISCOVERY=off (EXPERIMENTAL — Obsidian's undocumented registry format, stable since 1.0 but could change without notice; parse failures never crash the server, just surface via vault.list.obsidianConfigError) Platform-path resolution anchored on os.homedir() with escape hatch env var KOBSIDIAN_OBSIDIAN_CONFIG for portable installs / WSL: - darwin ~/Library/Application Support/obsidian/obsidian.json - win32 %APPDATA%\obsidian\obsidian.json (with home fallback) - linux $XDG_CONFIG_HOME/obsidian/... → ~/.config/... → flatpak → snap Security gating: - KOBSIDIAN_VAULT_ALLOW=name1,name2,/abs/path — allowlist - KOBSIDIAN_VAULT_DENY=... — denylist (applied after allowlist) - OBSIDIAN_VAULT_PATH is NEVER filtered by allow/deny to prevent operator self-lockout. Error UX: vault.select distinguishes "vault doesn't exist" (not_found) from "vault exists but is blocked by operator gating" (unauthorized) by resolving against the ungated pool, then applying allow/deny. Other: - vault.select on workspace.* / commands.* tools: surfaced as a note on every tool description via buildDescription() in create-server.ts, so LLMs know workspace/commands bridge to the live Obsidian instance (OBSIDIAN_API_URL) and are not affected by the filesystem selection. - New pick-vault server prompt for slash-command discoverability. - Fixed stale ingest-source prompt references to removed v0.2.5 tools (notes.insertAfterHeading / notes.append → notes.edit modes). Cross-platform testing: - 4 new test files: vault-paths, vault-discovery, vault-precedence, vault-tools (49 assertions across platform mocks, fixtures, precedence, real integration). - 7 fixture obsidian.json files (macOS / Windows escaped backslashes / Linux / malformed / no-vaults / empty-vaults / with-unknown-keys). - Platform-path tests feed mocked {platform, home, env} so they pass on every CI runner without needing the real OS. Docs: README, AGENTS.md, docs/tools.md, docs/ENVIRONMENT.md, docs/MIGRATION.md, CHANGELOG.md all updated. Version bumped to 0.3.0 across package.json, server.json, manifest.json. Verification: - bun run typecheck — clean - bun run lint (biome) — clean - bun run test — 111/111 passing (+55 vs v0.2.5) - bun run build — stdio + http bundles built - docs/tool-inventory.json regenerated — 66 tools Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 92 ++++ README.md | 247 +++++++++- biome.json | 8 +- docs/ENVIRONMENT.md | 69 ++- docs/MIGRATION.md | 72 +++ docs/tool-inventory.json | 44 ++ docs/tools.md | 15 +- manifest.json | 4 +- package.json | 2 +- server.json | 4 +- src/config/env.ts | 47 ++ src/domain/context.ts | 28 +- src/domain/vaults.ts | 453 ++++++++++++++++++ src/schema/vaults.ts | 176 +++++++ src/server/create-server.ts | 49 +- src/server/prompts.ts | 41 +- src/server/registry.ts | 2 + src/server/tools/vaults.ts | 180 +++++++ tests/fixtures/obsidian/empty-vaults.json | 1 + tests/fixtures/obsidian/linux-real.json | 13 + tests/fixtures/obsidian/macos-real.json | 14 + tests/fixtures/obsidian/malformed.json | 1 + tests/fixtures/obsidian/no-vaults.json | 4 + tests/fixtures/obsidian/windows-real.json | 14 + .../fixtures/obsidian/with-unknown-keys.json | 13 + tests/vault-discovery.test.ts | 376 +++++++++++++++ tests/vault-paths.test.ts | 135 ++++++ tests/vault-precedence.test.ts | 93 ++++ tests/vault-tools.test.ts | 170 +++++++ 29 files changed, 2321 insertions(+), 46 deletions(-) create mode 100644 src/domain/vaults.ts create mode 100644 src/schema/vaults.ts create mode 100644 src/server/tools/vaults.ts create mode 100644 tests/fixtures/obsidian/empty-vaults.json create mode 100644 tests/fixtures/obsidian/linux-real.json create mode 100644 tests/fixtures/obsidian/macos-real.json create mode 100644 tests/fixtures/obsidian/malformed.json create mode 100644 tests/fixtures/obsidian/no-vaults.json create mode 100644 tests/fixtures/obsidian/windows-real.json create mode 100644 tests/fixtures/obsidian/with-unknown-keys.json create mode 100644 tests/vault-discovery.test.ts create mode 100644 tests/vault-paths.test.ts create mode 100644 tests/vault-precedence.test.ts create mode 100644 tests/vault-tools.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5265986..c381c54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,98 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] — 2026-04-25 + +> **Multi-vault support.** kObsidian can now discover and switch between +> multiple Obsidian vaults in a single server session. The four new +> `vault.*` tools (`list`, `current`, `select`, `reset`) let an LLM +> enumerate the user's known vaults and change the active vault for +> subsequent filesystem tool calls — without the user having to restart +> the server or thread `vaultPath` through every call. +> +> **Fully backwards compatible.** `OBSIDIAN_VAULT_PATH` continues to +> work exactly as in v0.2.5; if `vault.select` is never called, every +> tool resolves to the same path as before. No existing tool signatures +> change. + +### Added + +- **`vault.*` namespace (4 tools, +1 new namespace)** — bringing the surface to + **66 tools across 16 namespaces**. + - `vault.list` — discover known vaults merged from three sources. + - `vault.current` — echo the active vault + precedence explanation. + - `vault.select` — set the session-active vault (by `id`, `name`, or `path`). + - `vault.reset` — clear the session selection, fall back to env default. +- **Named env-var vaults.** `OBSIDIAN_VAULT_=path` env vars + register additional vaults (suffix becomes the lowercase name). + Documented, stable, and the recommended path for explicit multi-vault + setups. +- **obsidian.json discovery (EXPERIMENTAL).** When + `KOBSIDIAN_VAULT_DISCOVERY=on` (default), kObsidian parses Obsidian's + undocumented obsidian.json vault registry so `vault.list` returns the + user's real vaults with zero config. Platform-specific paths + (macOS `~/Library/Application Support/…`, Windows `%APPDATA%\obsidian\…`, + Linux XDG + Flatpak + Snap). Escape hatch: + `KOBSIDIAN_OBSIDIAN_CONFIG=/absolute/path/obsidian.json` for portable + installs and WSL. Marked experimental because the format is internal + to Obsidian and could change without notice; parse failures are + surfaced via `vault.list.obsidianConfigError` and never crash the + server. +- **Operator gating env vars.** `KOBSIDIAN_VAULT_ALLOW` (comma-separated + allowlist, names or paths) and `KOBSIDIAN_VAULT_DENY` (denylist, + applied after allowlist). `OBSIDIAN_VAULT_PATH` is never filtered by + either to prevent accidental self-lockout. +- **`pick-vault` prompt.** A new MCP server-side prompt template that + clients like Claude Desktop surface as a slash command; it walks + `vault.list` → presents options → calls `vault.select`. +- **Session-active-vault addendum on every filesystem tool.** Descriptions + for every tool in `notes.*`, `tags.*`, `dataview.*`, `blocks.*`, + `canvas.*`, `kanban.*`, `marp.*`, `templates.*`, `tasks.*`, `links.*`, + `wiki.*`, and `stats.vault` now explain the precedence chain inline. + `workspace.*` and `commands.*` get the inverse note: they target the + live Obsidian process's vault, not the filesystem selection. +- **Cross-platform CI matrix.** New `.github/workflows/ci.yml` runs + `typecheck + lint + test + build` on `ubuntu-latest` + `macos-latest` + + `windows-latest` for every PR and push to main. Was ubuntu-only + via `release.yml`'s `prepublishOnly` gate before. + +### Changed + +- **`requireVaultPath` precedence** is now `arg > session > env > error` + (was `arg > env > error`). The middle slot is populated by + `vault.select`; absent when no select has happened, so behaviour + collapses to v0.2.5 exactly. +- **`DomainContext` type** extended with `session: SessionState` + + `vaults: VaultCache`. Additive — existing consumers that destructure + `{env, api}` continue to work unmodified. +- **`AppEnv` type** extended with `namedVaults: Record` + parsed from `OBSIDIAN_VAULT_=path` env vars. + +### Fixed + +- `ingest-source` prompt updated to reference `notes.edit` (with + `mode: 'after-heading'` / `mode: 'append'`) instead of the removed + `notes.insertAfterHeading` / `notes.append` (v0.2.5 fallout that + slipped through that release's prompt text). + +### Migration from 0.2.5 + +Zero required changes — `OBSIDIAN_VAULT_PATH` behaviour is identical. +Opt in to multi-vault by adding named env vars or using `vault.select`: + +```diff +# Before (still works in 0.3.0) +- OBSIDIAN_VAULT_PATH=/Users/alice/Vaults/Personal + +# After — optional multi-vault ++ OBSIDIAN_VAULT_PATH=/Users/alice/Vaults/Personal ++ OBSIDIAN_VAULT_WORK=/Users/alice/Vaults/Work ++ OBSIDIAN_VAULT_SCRATCH=/Users/alice/Vaults/Scratch +``` + +Then from the LLM: `vault.select { name: "work" }` to switch, +`vault.reset` to return to the default. + ## [0.2.5] — 2026-04-25 > **Tool-surface consolidation.** The tool surface has been reduced from ~90 diff --git a/README.md b/README.md index a7a5052..cdfb318 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,9 @@ You curate the sources; the LLM does the bookkeeping. ## Why kObsidian -- **Filesystem-first.** Operates on your vault directly. Obsidian doesn't need to be running for 55+ of the 62 tools. -- **62 typed MCP tools** across notes, links, tags, tasks, Dataview, Canvas, Kanban, fenced blocks, Marp, Templates — every one Zod-validated with `structuredContent` output and the full 4-hint MCP annotation set (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`). +- **Filesystem-first.** Operates on your vault directly. Obsidian doesn't need to be running for 55+ of the 66 tools. +- **66 typed MCP tools** across vaults, notes, links, tags, tasks, Dataview, Canvas, Kanban, fenced blocks, Marp, Templates — every one Zod-validated with `structuredContent` output and the full 4-hint MCP annotation set (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`). +- **Multi-vault `vault.*` (v0.3.0).** The LLM can `vault.list` your known Obsidian vaults (discovered from Obsidian's own registry or `OBSIDIAN_VAULT_` env vars) and `vault.select` between them for the session. Fully backwards compatible: `OBSIDIAN_VAULT_PATH` stays the default and per-call `vaultPath` arguments always win. - **LLM-Wiki orchestration** — a `wiki.*` namespace that turns your vault into a compounding knowledge base: ingest sources, auto-update an index + greppable log, lint for orphans / broken links / stale pages. Agent applies cross-refs via a `proposedEdits` contract so every write is visible in the transcript. - **Both transports.** Classic stdio for local MCP clients and Streamable HTTP (Hono) for remote, with CORS preflight, `MCP-Protocol-Version` handling, origin 403, and optional bearer auth — all per the 2025-11-25 spec. - **Ships everywhere.** npm (`npx -y kobsidian-mcp`), cross-platform `.mcpb` bundles for Claude Desktop drag-and-drop, a `smithery.yaml` for Smithery, and a `server.json` for the MCP Registry. Each `.mcpb` release asset is VirusTotal-scanned with links appended to the release body. @@ -52,11 +53,78 @@ You curate the sources; the LLM does the bookkeeping. ## Install -Pick the channel that matches your client. All three read the same -`OBSIDIAN_VAULT_PATH`, `OBSIDIAN_API_URL`, and `OBSIDIAN_REST_API_KEY` -environment variables — see [`docs/ENVIRONMENT.md`](docs/ENVIRONMENT.md). +Pick your client below. **Every client supports the full hybrid mode** +— filesystem-first tools (80+ of them) run against the vault path +alone, and the same config can *simultaneously* carry the Local REST +API key to unlock `workspace.*`, `commands.*`, and live DQL via +`dataview.query*`. Set the whole env block once per client and every +tool namespace lights up; leave the REST key blank and the +filesystem-first tools keep working. -### `npx` / `bunx` — Claude Code, Claude Desktop, Cursor, VSCode, Antigravity, Zed, Cline, JetBrains AI, … +| Env var | Needed for | +|---|---| +| `OBSIDIAN_VAULT_PATH` | **Required everywhere.** Absolute path to the vault. | +| `OBSIDIAN_API_URL` | Base URL of the Local REST API plugin. Default `https://127.0.0.1:27124`. | +| `OBSIDIAN_API_VERIFY_TLS` | `false` unless you've trusted the REST API's self-signed cert. | +| `OBSIDIAN_REST_API_KEY` | Local REST API plugin bearer key — only for `workspace.*` / `commands.*` / live `dataview.query*`. | + +Full list in [`docs/ENVIRONMENT.md`](docs/ENVIRONMENT.md). Swap `npx` +for `bunx` anywhere if you want ≈10 ms cold-start instead of ≈200 ms. + +
+Claude Codeclaude mcp add + +```bash +claude mcp add kobsidian -s user \ + --env OBSIDIAN_VAULT_PATH=/absolute/path/to/vault \ + --env OBSIDIAN_API_URL=https://127.0.0.1:27124 \ + --env OBSIDIAN_API_VERIFY_TLS=false \ + --env OBSIDIAN_REST_API_KEY=only-if-you-use-workspace-or-commands-tools \ + -- npx -y kobsidian-mcp +``` + +On Windows, wrap the command in `cmd /c`: +`-- cmd /c npx -y kobsidian-mcp`. + +
+ +
+Claude Desktop — drag-and-drop .mcpb + +Download `kobsidian-.mcpb` from the +[latest release](https://github.com/bezata/kObsidian/releases/latest) and +drag it into Claude Desktop. The installer prompts for vault path + +optional API URL / key. Every release asset is VirusTotal-scanned — the +links are in the release body. + +Build one locally: + +```bash +bun install +bun run build:compile # → dist/kobsidian (or .exe on Windows) +bun run bundle:mcpb # → kobsidian.mcpb +``` + +
+ +
+Codex CLI (OpenAI) — codex mcp add + +```bash +codex mcp add kobsidian \ + --env OBSIDIAN_VAULT_PATH=/absolute/path/to/vault \ + --env OBSIDIAN_API_URL=https://127.0.0.1:27124 \ + --env OBSIDIAN_API_VERIFY_TLS=false \ + --env OBSIDIAN_REST_API_KEY=only-if-you-use-workspace-or-commands-tools \ + -- npx -y kobsidian-mcp +``` + +
+ +
+Cursor~/.cursor/mcp.json or deeplink + +Edit `~/.cursor/mcp.json` (or the per-project `.cursor/mcp.json`): ```json { @@ -76,31 +144,160 @@ environment variables — see [`docs/ENVIRONMENT.md`](docs/ENVIRONMENT.md). } ``` -`"type": "stdio"` is optional on clients that infer transport from -`command` (Claude Code), but **required by Claude Desktop, Cursor, -VSCode, and Antigravity** — include it for maximum portability. Swap -`npx` for `bunx` for ≈10 ms cold-start instead of ≈200 ms. +Or hand a one-click deeplink to your users: +`cursor://anysphere.cursor-deeplink/mcp/install?name=kobsidian&config=`. -### Claude Desktop — drag-and-drop `.mcpb` +
-Download `kobsidian-.mcpb` from the -[latest release](https://github.com/bezata/kObsidian/releases/latest) and -drag it into Claude Desktop. The installer prompts for vault path + -optional API URL / key. Every release asset is scanned — the VirusTotal -links are in the release body. +
+VS Code (Copilot) — code --add-mcp -Build one locally: +```bash +code --add-mcp '{"name":"kobsidian","command":"npx","args":["-y","kobsidian-mcp"],"env":{"OBSIDIAN_VAULT_PATH":"/absolute/path/to/vault","OBSIDIAN_API_URL":"https://127.0.0.1:27124","OBSIDIAN_API_VERIFY_TLS":"false","OBSIDIAN_REST_API_KEY":"only-if-you-use-workspace-or-commands-tools"}}' +``` + +Or create `.vscode/mcp.json` in your workspace with the same shape under +a top-level `servers` key. + +
+ +
+Gemini CLIgemini mcp add ```bash -bun install -bun run build:compile # → dist/kobsidian (or .exe on Windows) -bun run bundle:mcpb # → kobsidian.mcpb +gemini mcp add kobsidian \ + --env OBSIDIAN_VAULT_PATH=/absolute/path/to/vault \ + --env OBSIDIAN_API_URL=https://127.0.0.1:27124 \ + --env OBSIDIAN_API_VERIFY_TLS=false \ + --env OBSIDIAN_REST_API_KEY=only-if-you-use-workspace-or-commands-tools \ + -- npx -y kobsidian-mcp ``` +Or hand-edit `~/.gemini/settings.json` under `mcpServers`. + +
+ +
+Antigravity (Google) — mcp_config.json + +Edit `~/.gemini/antigravity/mcp_config.json` +(Windows: `%USERPROFILE%\.gemini\antigravity\mcp_config.json`): + +```json +{ + "mcpServers": { + "kobsidian": { + "type": "stdio", + "command": "npx", + "args": ["-y", "kobsidian-mcp"], + "env": { + "OBSIDIAN_VAULT_PATH": "/absolute/path/to/vault", + "OBSIDIAN_API_URL": "https://127.0.0.1:27124", + "OBSIDIAN_API_VERIFY_TLS": "false", + "OBSIDIAN_REST_API_KEY": "only-if-you-use-workspace-or-commands-tools" + } + } + } +} +``` + +
+ +
+Zedsettings.json under context_servers + +In `~/.config/zed/settings.json`: + +```json +{ + "context_servers": { + "kobsidian": { + "source": "custom", + "command": "npx", + "args": ["-y", "kobsidian-mcp"], + "env": { + "OBSIDIAN_VAULT_PATH": "/absolute/path/to/vault", + "OBSIDIAN_API_URL": "https://127.0.0.1:27124", + "OBSIDIAN_API_VERIFY_TLS": "false", + "OBSIDIAN_REST_API_KEY": "only-if-you-use-workspace-or-commands-tools" + } + } + } +} +``` + +
+ +
+OpenCodeopencode.json under mcp + +```json +{ + "mcp": { + "kobsidian": { + "type": "local", + "command": ["npx", "-y", "kobsidian-mcp"], + "environment": { + "OBSIDIAN_VAULT_PATH": "/absolute/path/to/vault", + "OBSIDIAN_API_URL": "https://127.0.0.1:27124", + "OBSIDIAN_API_VERIFY_TLS": "false", + "OBSIDIAN_REST_API_KEY": "only-if-you-use-workspace-or-commands-tools" + } + } + } +} +``` + +
+ +
+Factory Droiddroid mcp add + +```bash +droid mcp add kobsidian "npx -y kobsidian-mcp" \ + --env OBSIDIAN_VAULT_PATH=/absolute/path/to/vault \ + --env OBSIDIAN_API_URL=https://127.0.0.1:27124 \ + --env OBSIDIAN_API_VERIFY_TLS=false \ + --env OBSIDIAN_REST_API_KEY=only-if-you-use-workspace-or-commands-tools +``` + +
+ +
+Other clients (Cline, JetBrains AI, Continue, custom hosts) — generic mcpServers JSON + +Any MCP client that reads a standard `mcpServers` object will accept: + +```json +{ + "mcpServers": { + "kobsidian": { + "type": "stdio", + "command": "npx", + "args": ["-y", "kobsidian-mcp"], + "env": { + "OBSIDIAN_VAULT_PATH": "/absolute/path/to/vault", + "OBSIDIAN_API_URL": "https://127.0.0.1:27124", + "OBSIDIAN_API_VERIFY_TLS": "false", + "OBSIDIAN_REST_API_KEY": "only-if-you-use-workspace-or-commands-tools" + } + } + } +} +``` + +`"type": "stdio"` is optional on clients that infer transport from +`command` (Claude Code), but **required by Claude Desktop, Cursor, +VSCode, and Antigravity** — include it for maximum portability. + +
+ ### Smithery [smithery.ai](https://smithery.ai) renders an install UI straight from -[`smithery.yaml`](smithery.yaml) and collects the three env vars for you. +[`smithery.yaml`](smithery.yaml) and collects the four env vars for you +— vault path plus the optional Local REST API URL / TLS / bearer key +trio, so hybrid mode works out of the box. ### From source (contributing / hacking) @@ -115,7 +312,7 @@ bun run dev:stdio # or dev:http ## Obsidian plugins -kObsidian is **filesystem-first** — 55+ of the 62 tools work against a +kObsidian is **filesystem-first** — 55+ of the 66 tools work against a bare vault directory with no Obsidian plugins installed. The plugins below only matter if you want the specific tool namespaces that depend on them. @@ -436,12 +633,14 @@ symlink them into `~/.claude/skills/` — see ## Tool surface -**62 MCP tools across 15 namespaces** (down from ~90 in v0.2.x — see -[CHANGELOG](CHANGELOG.md) for the full migration table). Always-current +**66 MCP tools across 16 namespaces** (v0.2.5 consolidated from ~90 to 62; +v0.3.0 added the `vault.*` namespace for multi-vault support — see +[CHANGELOG](CHANGELOG.md) for the full history). Always-current inventory at **[`docs/tool-inventory.json`](docs/tool-inventory.json)**. | Namespace | Count | Highlights | |---|---:|---| +| `vault.*` | 4 | `list` · `current` · `select` · `reset` — multi-vault discovery and session switching (v0.3.0) | | `notes.*` | 8 | `read` (content/metadata/stats via `include`) · `create` (note or folder) · `edit` (replace/append/prepend/after-heading/after-block) · `frontmatter` · `delete` · `move` · `list` · `search` | | `tags.*` | 4 | `modify` (add/remove/replace/merge) · `search` · `analyze` · `list` | | `links.*` | 8 | Backlinks · outgoing · broken · orphans · hubs · graph · health · connections | diff --git a/biome.json b/biome.json index d4a79c7..aaa5d30 100644 --- a/biome.json +++ b/biome.json @@ -19,6 +19,12 @@ } }, "files": { - "ignore": [".claude", "dist", "coverage", "node_modules"] + "ignore": [ + ".claude", + "dist", + "coverage", + "node_modules", + "tests/fixtures/obsidian/malformed.json" + ] } } diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index f7a81ae..44922a2 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -3,27 +3,88 @@ Use environment variables for local configuration. Do not commit real values. ```bash +# Core (unchanged since v0.2.x) OBSIDIAN_VAULT_PATH=/absolute/path/to/vault OBSIDIAN_API_URL=https://127.0.0.1:27124 OBSIDIAN_API_VERIFY_TLS=false OBSIDIAN_REST_API_KEY=your-local-rest-api-key + +# HTTP transport KOBSIDIAN_HTTP_HOST=127.0.0.1 KOBSIDIAN_HTTP_PORT=3000 KOBSIDIAN_HTTP_BEARER_TOKEN=optional-local-http-token KOBSIDIAN_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1 + +# Multi-vault (v0.3.0) — all optional; OBSIDIAN_VAULT_PATH alone still works +OBSIDIAN_VAULT_WORK=/absolute/path/to/work-vault +OBSIDIAN_VAULT_PERSONAL=/absolute/path/to/personal-vault +KOBSIDIAN_VAULT_DISCOVERY=on # on|off — parse Obsidian's obsidian.json; default on +KOBSIDIAN_VAULT_ALLOW=work,personal # comma-separated allowlist (names or absolute paths); unset = allow all +KOBSIDIAN_VAULT_DENY=secrets # comma-separated denylist; applied after allowlist +KOBSIDIAN_OBSIDIAN_CONFIG= # override path to obsidian.json (portable installs, WSL) ``` -For Obsidian Local REST API, the default HTTPS certificate is self-signed. Keep `OBSIDIAN_API_VERIFY_TLS=false` for local development unless you install and trust the certificate. +For Obsidian Local REST API, the default HTTPS certificate is self-signed. +Keep `OBSIDIAN_API_VERIFY_TLS=false` for local development unless you +install and trust the certificate. `OBSIDIAN_REST_API_KEY` is the API key generated by the [Local REST API](obsidian://show-plugin?id=obsidian-local-rest-api) plugin — see the [Obsidian plugins section in the README](../README.md#obsidian-plugins) for install + key generation -steps. Only required for `workspace.*`, `commands.*`, and the -runtime `dataview.query*` / `templates.*Templater` tools; the 80+ +steps. Only required for `workspace.*`, `commands.*`, and the runtime +`dataview.query*` / Templater branches of `templates.use`; the 55+ filesystem-first tools work without it. -Quick local checks: +## Multi-vault (v0.3.0) + +kObsidian supports multiple vaults in a single server via the `vault.*` +namespace (`vault.list`, `vault.current`, `vault.select`, `vault.reset`). +The precedence chain for which vault a filesystem tool resolves to is: + +1. Per-call `vaultPath` argument (highest) +2. Session selection made via `vault.select` +3. `OBSIDIAN_VAULT_PATH` (fallback / default — unchanged from v0.2.x) +4. Error if none of the above is set + +### How vaults are discovered + +| Source | What it reads | Status | +|---|---|---| +| `OBSIDIAN_VAULT_PATH` | The single default vault | Documented, stable | +| `OBSIDIAN_VAULT_=path` | Named extras, e.g. `OBSIDIAN_VAULT_WORK=/…`, `OBSIDIAN_VAULT_PERSONAL=/…` | Documented, stable | +| `obsidian.json` | Obsidian's own vault registry on disk | **Experimental** — the format is undocumented and internal to Obsidian. Stable in practice since v1.0, but could change without notice. Toggle with `KOBSIDIAN_VAULT_DISCOVERY=on\|off` (default `on`). | + +The `obsidian-app` source is best-effort: any parse failure is surfaced +via `vault.list`'s `obsidianConfigError` response field and never crashes +the server. If you want zero dependency on Obsidian internals, set +`KOBSIDIAN_VAULT_DISCOVERY=off` and manage your vaults via env vars +only. + +### `obsidian.json` paths per OS + +| OS | Default path | +|---|---| +| macOS | `~/Library/Application Support/obsidian/obsidian.json` | +| Windows | `%APPDATA%\obsidian\obsidian.json` | +| Linux (native) | `$XDG_CONFIG_HOME/obsidian/obsidian.json` → `~/.config/obsidian/obsidian.json` | +| Linux (Flatpak) | `~/.var/app/md.obsidian.Obsidian/config/obsidian/obsidian.json` | +| Linux (Snap) | `~/snap/obsidian/current/.config/obsidian/obsidian.json` | +| Portable / WSL / non-standard | Set `KOBSIDIAN_OBSIDIAN_CONFIG=/absolute/path/obsidian.json` | + +### Security / gating + +| Var | Default | Effect | +|---|---|---| +| `KOBSIDIAN_VAULT_ALLOW` | unset (allow all) | Allowlist (names or absolute paths, comma-separated). Non-matching vaults are filtered out of `vault.list` and rejected by `vault.select` with `unauthorized`. | +| `KOBSIDIAN_VAULT_DENY` | unset | Denylist, applied after the allowlist. | + +`OBSIDIAN_VAULT_PATH` is **never** filtered by allow/deny — it's the +operator-blessed default and always remains reachable, even if the +deny rule would otherwise match it. This prevents accidental +self-lockout. + +## Quick local checks ```bash bun run dev:stdio diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 3e2ab53..e2d2aa1 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -1,5 +1,77 @@ # Migration Guide +## v0.3.0 — Multi-vault support (2026-04-25) + +v0.3.0 adds a `vault.*` namespace (4 tools) that lets an LLM discover +and switch between multiple Obsidian vaults in a single server session. +**Fully backwards compatible** — `OBSIDIAN_VAULT_PATH` behaviour is +identical to v0.2.5, no existing tool signatures change, and all 111 +tests (the 56 from v0.2.5 plus 55 new ones) pass unmodified. + +### What's new + +- **`vault.list`, `vault.current`, `vault.select`, `vault.reset`** — see + [`tools.md`](tools.md) for the four new tools. +- **Named env-var vaults.** Set `OBSIDIAN_VAULT_WORK=…`, + `OBSIDIAN_VAULT_PERSONAL=…`, etc. Suffix is lowercased to become the + vault name. +- **`obsidian.json` discovery (experimental).** `vault.list` can surface + vaults from Obsidian's own registry file with zero configuration. + Marked experimental because the file format is internal to Obsidian + and undocumented — toggle via `KOBSIDIAN_VAULT_DISCOVERY=on|off` + (default `on`). +- **Operator gating.** `KOBSIDIAN_VAULT_ALLOW` / `KOBSIDIAN_VAULT_DENY` + restrict which vaults the LLM can see and select. + `OBSIDIAN_VAULT_PATH` is never filtered by these — the operator's + blessed default always stays reachable. +- **`pick-vault` server prompt** — slash command that walks `vault.list` + and calls `vault.select`. +- **Cross-platform CI matrix** — every PR now runs typecheck, lint, + tests, and build on `ubuntu-latest` + `macos-latest` + `windows-latest`. + +### Precedence change + +`requireVaultPath` gained a middle slot: + +``` +v0.2.5: args.vaultPath > OBSIDIAN_VAULT_PATH > throw +v0.3.0: args.vaultPath > vault.select'd vault > OBSIDIAN_VAULT_PATH > throw +``` + +If you never call `vault.select`, the chain collapses to the v0.2.5 +behaviour exactly. **No migration required** for existing deployments. + +### Env var additions + +| Var | Default | Meaning | +|---|---|---| +| `OBSIDIAN_VAULT_` | unset | Additional named vault (suffix = name, lowercased). | +| `KOBSIDIAN_VAULT_DISCOVERY` | `on` | Toggle obsidian.json parsing. | +| `KOBSIDIAN_VAULT_ALLOW` | unset | Comma-separated allowlist (names or paths). | +| `KOBSIDIAN_VAULT_DENY` | unset | Comma-separated denylist. | +| `KOBSIDIAN_OBSIDIAN_CONFIG` | unset | Explicit path to obsidian.json (portable installs, WSL). | + +See [`ENVIRONMENT.md`](ENVIRONMENT.md) for the full table. + +### Before / after + +```diff +# Environment — both forms still work +- OBSIDIAN_VAULT_PATH=/Users/alice/Vaults/Personal ++ OBSIDIAN_VAULT_PATH=/Users/alice/Vaults/Personal ++ OBSIDIAN_VAULT_WORK=/Users/alice/Vaults/Work ++ OBSIDIAN_VAULT_SCRATCH=/Users/alice/Vaults/Scratch + +# From the LLM — new in v0.3.0 ++ vault.list {} ++ vault.select { name: "work" } ++ notes.list {} # now hits Vaults/Work ++ vault.reset {} ++ notes.list {} # back to Vaults/Personal (the env default) +``` + +--- + ## v0.2.5 — Tool-surface consolidation (2026-04-25) v0.2.5 reduces the tool surface from ~90 to **62 tools across 15 namespaces** diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 7d36ce1..0ba2bd1 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -1,4 +1,48 @@ [ + { + "name": "vault.list", + "title": "List Known Vaults", + "description": "List every Obsidian vault kObsidian knows about, merged and deduplicated across three sources: the operator's OBSIDIAN_VAULT_PATH (the default — always included), any OBSIDIAN_VAULT_=path env vars (explicit named vaults), and — when KOBSIDIAN_VAULT_DISCOVERY is `on` (the default) — the user's local Obsidian application registry at obsidian.json. Each item reports its `source`, `isDefault`, `isActive`, and `exists` so the LLM can flag stale or missing vaults. Pass `refresh: true` to force a fresh scan instead of using the 30s cache. Read-only. NOTE: the `obsidian-app` source is EXPERIMENTAL — it parses Obsidian's undocumented obsidian.json registry (stable since 1.0 but internal to Obsidian) and may silently stop returning results if Obsidian changes the format; the env-var sources are the documented, stable path.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "vault.current", + "title": "Current Active Vault", + "description": "Return the vault that filesystem tools (notes.*, tags.*, dataview.*, blocks.*, canvas.*, kanban.*, marp.*, templates.*, tasks.*, links.*, wiki.*, stats.vault) would resolve to right now, plus the full precedence chain so the LLM can explain to the user why that vault was picked. `reason` is `session-selected` (vault.select was called), `env-default` (fell back to OBSIDIAN_VAULT_PATH), or `none` (nothing configured — tools will fail until vault.select or an env var is set). When OBSIDIAN_API_URL is configured, the response also carries an `obsidianLiveInstance` note reminding the caller that workspace.* and commands.* tools target whichever vault the live Obsidian process has open, NOT the filesystem vault selected here. Read-only.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "vault.select", + "title": "Select Active Vault", + "description": "Set the session-active vault for subsequent filesystem tool calls. Identify the target by EXACTLY ONE of `id` (stable id from vault.list), `name` (case-insensitive match), or `path` (absolute directory path — need not appear in vault.list; lets the LLM point at a fresh/empty vault to initialise). Precedence chain becomes: per-call `vaultPath` argument (highest) → this session selection → OBSIDIAN_VAULT_PATH → error. Explicit `vaultPath` arguments on individual tool calls always override this selection. Respects KOBSIDIAN_VAULT_ALLOW / KOBSIDIAN_VAULT_DENY operator gating (though OBSIDIAN_VAULT_PATH is never filtered). Does NOT change which vault the live Obsidian process has open — `workspace.*` and `commands.*` tools remain tied to OBSIDIAN_API_URL. HTTP deployments: this server shares the selection across HTTP clients, so concurrent multi-client HTTP setups should pass `vaultPath` per call instead.", + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + } + }, + { + "name": "vault.reset", + "title": "Reset Active Vault Selection", + "description": "Clear the session-selected vault so the precedence chain falls back to OBSIDIAN_VAULT_PATH. Use this to signal 'I'm done with the scratch vault, go back to the default'. Idempotent — running on an already-cleared session is a no-op that reports `changed: false`. Does not change per-call `vaultPath` behaviour.", + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, { "name": "notes.read", "title": "Read Note", diff --git a/docs/tools.md b/docs/tools.md index 46653b3..7cd6fd8 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,17 +1,20 @@ # Tool surface -kObsidian exposes **62 MCP tools across 15 namespaces** plus 4 resources -and 3 prompts. The canonical, always-up-to-date list is generated by +kObsidian exposes **66 MCP tools across 16 namespaces** plus 4 resources +and 4 prompts. The canonical, always-up-to-date list is generated by `bun run inventory` into **[`tool-inventory.json`](tool-inventory.json)**. -> v0.2.5 consolidated the surface from ~90 tools to 62. See -> [`../CHANGELOG.md`](../CHANGELOG.md) and [`MIGRATION.md`](MIGRATION.md) -> for the full rename/merge table. +> - v0.2.5 consolidated the surface from ~90 tools to 62. +> - v0.3.0 added the `vault.*` namespace (4 tools, 62 → 66) for +> multi-vault discovery and selection. See +> [`../CHANGELOG.md`](../CHANGELOG.md) and +> [`MIGRATION.md`](MIGRATION.md) for the full history. ## Namespaces | Namespace | Count | What it does | |---|---|---| +| `vault.*` | 4 | `list` (discover known vaults), `current` (echo active), `select` (switch), `reset` (clear selection). Backwards compatible with `OBSIDIAN_VAULT_PATH`. | | `notes.*` | 8 | `read` (content/metadata/stats), `create` (note or folder), `edit` (replace/append/prepend/after-heading/after-block), `frontmatter`, `delete`, `move`, `list`, `search` | | `tags.*` | 4 | `modify` (add/remove/replace/merge), `search`, `analyze`, `list` | | `links.*` | 8 | `backlinks`, `outgoing`, `broken`, `graph`, `orphaned`, `hubs`, `health`, `connections` | @@ -27,7 +30,7 @@ and 3 prompts. The canonical, always-up-to-date list is generated by | `commands.*` | 2 | `list` (with optional query), `execute` | | `wiki.*` | 7 | LLM-Wiki orchestration (`init`/`ingest`/`logAppend`/`indexRebuild`/`query`/`lint`/`summaryMerge`) | | `system.*` | 1 | `version` | -| **Total** | **62** | | +| **Total** | **66** | | ## Annotation summary diff --git a/manifest.json b/manifest.json index d98ada8..69fe402 100644 --- a/manifest.json +++ b/manifest.json @@ -2,9 +2,9 @@ "manifest_version": "0.2", "name": "kobsidian", "display_name": "kObsidian", - "version": "0.2.5", + "version": "0.3.0", "description": "Filesystem-first MCP server for Obsidian vaults with an LLM-Wiki layer.", - "long_description": "kObsidian exposes 62 MCP tools across notes, links, tags, tasks, Dataview, Canvas, Kanban, Marp, Templates, blocks, and an LLM-Wiki orchestration namespace (wiki.init, wiki.ingest, wiki.query, wiki.lint, etc.). Every tool description is written for high LLM/Glama compatibility with explicit MCP annotation hints and input examples. It operates directly on the vault filesystem and optionally bridges to the Obsidian Local REST API plugin for workspace/command actions.", + "long_description": "kObsidian exposes 66 MCP tools across notes, links, tags, tasks, Dataview, Canvas, Kanban, Marp, Templates, blocks, vaults, and an LLM-Wiki orchestration namespace (wiki.init, wiki.ingest, wiki.query, wiki.lint, etc.). v0.3.0 adds multi-vault support: vault.list discovers the user's Obsidian vaults, vault.select switches between them per session, and every existing tool remains fully backwards compatible with OBSIDIAN_VAULT_PATH. Every tool description is written for high LLM/Glama compatibility with explicit MCP annotation hints and input examples. It operates directly on the vault filesystem and optionally bridges to the Obsidian Local REST API plugin for workspace/command actions.", "author": { "name": "Behzat Can Acele", "email": "behzatcnacle@gmail.com" diff --git a/package.json b/package.json index a13d2ad..0b324ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kobsidian-mcp", - "version": "0.2.5", + "version": "0.3.0", "description": "TypeScript MCP server for Obsidian with a filesystem-first LLM Wiki layer, Dataview / Canvas / Kanban / Mermaid / Marp / Templates tools, and both stdio and Streamable HTTP transports.", "type": "module", "mcpName": "io.github.bezata/kobsidian-mcp", diff --git a/server.json b/server.json index f312746..0604def 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.bezata/kobsidian-mcp", "title": "kObsidian", "description": "Filesystem-first MCP for Obsidian — an LLM-maintained wiki inspired by Karpathy's LLM Wiki.", - "version": "0.2.5", + "version": "0.3.0", "repository": { "url": "https://github.com/bezata/kObsidian", "source": "github" @@ -14,7 +14,7 @@ "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "kobsidian-mcp", - "version": "0.2.5", + "version": "0.3.0", "runtimeHint": "npx", "transport": { "type": "stdio" diff --git a/src/config/env.ts b/src/config/env.ts index a3cab63..ebec275 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -21,12 +21,58 @@ const envSchema = z.object({ KOBSIDIAN_WIKI_LOG_FILE: z.string().default("log.md"), KOBSIDIAN_WIKI_SCHEMA_FILE: z.string().default("wiki-schema.md"), KOBSIDIAN_WIKI_STALE_DAYS: z.coerce.number().int().min(1).max(3650).default(180), + KOBSIDIAN_VAULT_DISCOVERY: z + .enum(["on", "off"]) + .default("on") + .describe( + "When `off`, kObsidian skips parsing Obsidian's obsidian.json registry — `vault.list` only returns explicit env-var entries.", + ), + KOBSIDIAN_VAULT_ALLOW: z + .string() + .optional() + .describe( + "Comma-separated allowlist (names or absolute paths) restricting which vaults appear in vault.list and are selectable. Unset = allow all. OBSIDIAN_VAULT_PATH is never filtered.", + ), + KOBSIDIAN_VAULT_DENY: z + .string() + .optional() + .describe( + "Comma-separated denylist (names or absolute paths). Applied after allowlist. OBSIDIAN_VAULT_PATH is never filtered.", + ), + KOBSIDIAN_OBSIDIAN_CONFIG: z + .string() + .optional() + .describe( + "Explicit path to Obsidian's obsidian.json. Escape hatch for portable installs, WSL, or non-standard locations.", + ), }); export type AppEnv = z.infer & { allowedOrigins: Set; + /** + * Named vaults parsed from `OBSIDIAN_VAULT_=path` env vars. Keys are + * the lowercased suffix; values are the vault path verbatim (not + * validated as a directory here — that happens at discovery time). + */ + namedVaults: Record; }; +const NAMED_VAULT_PREFIX = "OBSIDIAN_VAULT_"; +const NAMED_VAULT_RESERVED = new Set(["OBSIDIAN_VAULT_PATH"]); + +function extractNamedVaults(source: NodeJS.ProcessEnv): Record { + const result: Record = {}; + for (const [key, rawValue] of Object.entries(source)) { + if (!key.startsWith(NAMED_VAULT_PREFIX)) continue; + if (NAMED_VAULT_RESERVED.has(key)) continue; + if (typeof rawValue !== "string" || rawValue.length === 0) continue; + const suffix = key.slice(NAMED_VAULT_PREFIX.length); + if (suffix.length === 0) continue; + result[suffix.toLowerCase()] = rawValue; + } + return result; +} + export function getEnv(source: NodeJS.ProcessEnv = process.env): AppEnv { const parsed = envSchema.parse(source); return { @@ -36,5 +82,6 @@ export function getEnv(source: NodeJS.ProcessEnv = process.env): AppEnv { .map((value) => value.trim()) .filter(Boolean), ), + namedVaults: extractNamedVaults(source), }; } diff --git a/src/domain/context.ts b/src/domain/context.ts index 5806435..7d368e9 100644 --- a/src/domain/context.ts +++ b/src/domain/context.ts @@ -1,10 +1,31 @@ import { type AppEnv, getEnv } from "../config/env.js"; import { AppError } from "../lib/errors.js"; import { ObsidianApiClient } from "../lib/obsidian-api-client.js"; +import type { VaultRecord } from "../schema/vaults.js"; +import type { DiscoverResult } from "./vaults.js"; + +export type SessionState = { + /** + * The vault set by `vault.select`. Slots in between the per-call argument + * and the startup env default in `requireVaultPath`'s precedence chain. + * Null when no vault.select has been made — behaviour collapses to the + * pre-v0.3.0 `arg > env > error` flow. + */ + activeVault: VaultRecord | null; +}; + +export type VaultCache = { + /** Milliseconds since epoch of the last successful discovery refresh. */ + lastRefreshedAt: number; + /** Most recent discovery result, or null when discovery hasn't run yet. */ + result: DiscoverResult | null; +}; export type DomainContext = { env: AppEnv; api: ObsidianApiClient; + session: SessionState; + vaults: VaultCache; }; export function createDomainContext(env: AppEnv = getEnv()): DomainContext { @@ -15,15 +36,18 @@ export function createDomainContext(env: AppEnv = getEnv()): DomainContext { apiKey: env.OBSIDIAN_REST_API_KEY, verifyTls: env.OBSIDIAN_API_VERIFY_TLS, }), + session: { activeVault: null }, + vaults: { lastRefreshedAt: 0, result: null }, }; } export function requireVaultPath(context: DomainContext, vaultPath?: string): string { - const resolved = vaultPath ?? context.env.OBSIDIAN_VAULT_PATH; + const resolved = + vaultPath ?? context.session.activeVault?.path ?? context.env.OBSIDIAN_VAULT_PATH; if (!resolved) { throw new AppError( "invalid_argument", - "Vault path is required. Set OBSIDIAN_VAULT_PATH or pass vaultPath.", + "Vault path is required. Set OBSIDIAN_VAULT_PATH, call vault.select, or pass vaultPath.", ); } return resolved; diff --git a/src/domain/vaults.ts b/src/domain/vaults.ts new file mode 100644 index 0000000..edf4d52 --- /dev/null +++ b/src/domain/vaults.ts @@ -0,0 +1,453 @@ +import { promises as fs } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { AppError } from "../lib/errors.js"; +import type { VaultRecord, VaultSource } from "../schema/vaults.js"; + +// --------------------------------------------------------------------------- +// obsidian.json path resolution +// --------------------------------------------------------------------------- + +// Platform-specific candidate locations for Obsidian's global vault registry. +// Anchored on os.homedir() with an escape-hatch env var for portable installs +// (Obsidian Portable on Windows, non-standard Linux, WSL pointing at the +// Windows-side config, etc.). + +type CandidateDeps = { + platform: NodeJS.Platform; + home: string; + env: NodeJS.ProcessEnv; +}; + +export function obsidianConfigCandidates(deps: CandidateDeps): string[] { + const override = deps.env.KOBSIDIAN_OBSIDIAN_CONFIG; + if (override && override.length > 0) { + return [override]; + } + const { home } = deps; + const xdg = deps.env.XDG_CONFIG_HOME; + + switch (deps.platform) { + case "darwin": + return [path.join(home, "Library/Application Support/obsidian/obsidian.json")]; + case "win32": { + const appData = + deps.env.APPDATA && deps.env.APPDATA.length > 0 + ? deps.env.APPDATA + : path.join(home, "AppData/Roaming"); + return [path.join(appData, "obsidian/obsidian.json")]; + } + default: { + const linux: (string | undefined)[] = [ + xdg && xdg.length > 0 ? path.join(xdg, "obsidian/obsidian.json") : undefined, + path.join(home, ".config/obsidian/obsidian.json"), + path.join(home, ".var/app/md.obsidian.Obsidian/config/obsidian/obsidian.json"), + path.join(home, "snap/obsidian/current/.config/obsidian/obsidian.json"), + ]; + return linux.filter((p): p is string => typeof p === "string" && p.length > 0); + } + } +} + +export async function resolveObsidianConfigPath( + deps: CandidateDeps = { + platform: process.platform, + home: os.homedir(), + env: process.env, + }, +): Promise { + for (const candidate of obsidianConfigCandidates(deps)) { + try { + const stat = await fs.stat(candidate); + if (stat.isFile()) return candidate; + } catch { + // ENOENT / EACCES / anything else — try the next candidate. + } + } + return null; +} + +// --------------------------------------------------------------------------- +// obsidian.json parsing (defensive) +// --------------------------------------------------------------------------- + +export const obsidianVaultEntrySchema = z + .object({ + path: z.string().min(1), + ts: z.number().int().nonnegative().optional(), + open: z.boolean().optional(), + }) + .passthrough(); + +export const obsidianConfigSchema = z + .object({ + vaults: z.record(z.string(), obsidianVaultEntrySchema).optional(), + }) + .passthrough(); + +export type ParsedObsidianConfig = z.infer; + +export type ObsidianConfigParse = + | { ok: true; config: ParsedObsidianConfig } + | { ok: false; error: string }; + +export async function parseObsidianConfig(filePath: string): Promise { + let raw: string; + try { + raw = await fs.readFile(filePath, "utf8"); + } catch (err) { + return { ok: false, error: `read failed: ${(err as Error).message}` }; + } + + let json: unknown; + try { + json = JSON.parse(raw); + } catch (err) { + return { ok: false, error: `JSON parse failed: ${(err as Error).message}` }; + } + + const parsed = obsidianConfigSchema.safeParse(json); + if (!parsed.success) { + return { ok: false, error: `shape mismatch: ${parsed.error.issues[0]?.message ?? "unknown"}` }; + } + + return { ok: true, config: parsed.data }; +} + +// --------------------------------------------------------------------------- +// Vault record construction + merge +// --------------------------------------------------------------------------- + +type RawCandidate = { + id: string; + name: string; + path: string; + source: VaultSource; + lastOpened?: string; +}; + +/** + * Produce a canonical key for dedup: absolute, normalised, case-folded on + * Windows (NTFS is case-insensitive). On POSIX, preserve case. + */ +function canonicalPath(p: string, platform: NodeJS.Platform = process.platform): string { + // Obsidian's Windows paths arrive with backslashes; normalise + resolve + // gives us a canonical form regardless of input slashes. + const resolved = path.resolve(p); + return platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function basename(p: string): string { + return path.basename(p) || p; +} + +function candidatesFromEnvDefault(env: AppEnv): RawCandidate[] { + if (!env.OBSIDIAN_VAULT_PATH) return []; + return [ + { + id: "default", + name: basename(env.OBSIDIAN_VAULT_PATH), + path: env.OBSIDIAN_VAULT_PATH, + source: "env-default", + }, + ]; +} + +function candidatesFromEnvNamed(env: AppEnv): RawCandidate[] { + return Object.entries(env.namedVaults).map(([name, vaultPath]) => ({ + id: `env:${name}`, + name, + path: vaultPath, + source: "env-named" as const, + })); +} + +function candidatesFromObsidianApp(config: ParsedObsidianConfig | null): RawCandidate[] { + if (!config?.vaults) return []; + return Object.entries(config.vaults).map(([id, entry]) => ({ + id, + name: basename(entry.path), + path: entry.path, + source: "obsidian-app" as const, + lastOpened: + typeof entry.ts === "number" && Number.isFinite(entry.ts) + ? new Date(entry.ts).toISOString() + : undefined, + })); +} + +export type MergeInput = { + env: AppEnv; + obsidianConfig: ParsedObsidianConfig | null; + platform?: NodeJS.Platform; +}; + +/** + * Merge raw candidates across the three sources, dedup by canonical path, + * and attach `isDefault` and `exists` flags. `isActive` is not set here — + * it's applied by `applyActiveFlag` after merging because the session state + * lives on DomainContext, not in the discovery layer. + */ +export function mergeVaultSources(input: MergeInput): Omit[] { + const platform = input.platform ?? process.platform; + + // Priority order when the same path appears in multiple sources: + // env-default > env-named > obsidian-app + // — the higher-priority entry keeps its id/name, but we preserve + // `lastOpened` if an `obsidian-app` sibling has it. + const sourcePriority: Record = { + "env-default": 3, + "env-named": 2, + "obsidian-app": 1, + }; + + const raw: RawCandidate[] = [ + ...candidatesFromEnvDefault(input.env), + ...candidatesFromEnvNamed(input.env), + ...candidatesFromObsidianApp(input.obsidianConfig), + ]; + + const byKey = new Map(); + const lastOpenedByKey = new Map(); + + for (const candidate of raw) { + const key = canonicalPath(candidate.path, platform); + if (candidate.lastOpened) { + const existing = lastOpenedByKey.get(key); + if (!existing || candidate.lastOpened > existing) { + lastOpenedByKey.set(key, candidate.lastOpened); + } + } + const current = byKey.get(key); + if (!current || sourcePriority[candidate.source] > sourcePriority[current.source]) { + byKey.set(key, candidate); + } + } + + const defaultKey = input.env.OBSIDIAN_VAULT_PATH + ? canonicalPath(input.env.OBSIDIAN_VAULT_PATH, platform) + : null; + + return Array.from(byKey.entries()).map(([key, candidate]) => ({ + id: candidate.id, + name: candidate.name, + path: candidate.path, + isDefault: defaultKey !== null && key === defaultKey, + source: candidate.source, + lastOpened: lastOpenedByKey.get(key) ?? candidate.lastOpened, + })); +} + +/** + * Attach `exists` to every record (fs.stat-based) and the `isActive` flag + * based on the currently-selected vault id. + */ +export async function finaliseVaultRecords( + base: Omit[], + activeVaultId: string | null, + platform: NodeJS.Platform = process.platform, +): Promise { + const activeKey = base.find((v) => v.id === activeVaultId)?.path; + const activeCanonical = activeKey ? canonicalPath(activeKey, platform) : null; + + return Promise.all( + base.map(async (record) => { + let exists = false; + try { + const stat = await fs.stat(record.path); + exists = stat.isDirectory(); + } catch { + exists = false; + } + const recordCanonical = canonicalPath(record.path, platform); + return { + ...record, + exists, + isActive: activeCanonical !== null && recordCanonical === activeCanonical, + } satisfies VaultRecord; + }), + ); +} + +// --------------------------------------------------------------------------- +// Allow / deny gating +// --------------------------------------------------------------------------- + +function parseList(value: string | undefined): string[] { + if (!value) return []; + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +/** + * Apply allowlist + denylist to discovered vault records. Matching is + * case-insensitive on both name and path (normalised). OBSIDIAN_VAULT_PATH + * is never filtered — it's the operator's explicit default and must remain + * reachable regardless of allow/deny. + */ +export function applyAllowDeny( + records: VaultRecord[], + env: AppEnv, + platform: NodeJS.Platform = process.platform, +): VaultRecord[] { + const allow = parseList(env.KOBSIDIAN_VAULT_ALLOW).map((v) => v.toLowerCase()); + const deny = parseList(env.KOBSIDIAN_VAULT_DENY).map((v) => v.toLowerCase()); + + if (allow.length === 0 && deny.length === 0) return records; + + const defaultKey = env.OBSIDIAN_VAULT_PATH + ? canonicalPath(env.OBSIDIAN_VAULT_PATH, platform) + : null; + + const matches = (record: VaultRecord, patterns: string[]): boolean => { + const recordKey = canonicalPath(record.path, platform); + const nameLower = record.name.toLowerCase(); + return patterns.some((p) => { + // Treat absolute-path-ish patterns as paths; otherwise as names. + if (path.isAbsolute(p)) { + return canonicalPath(p, platform) === recordKey; + } + return p === nameLower; + }); + }; + + return records.filter((record) => { + const recordKey = canonicalPath(record.path, platform); + const isOperatorDefault = defaultKey !== null && recordKey === defaultKey; + if (isOperatorDefault) return true; // always allowed + + if (allow.length > 0 && !matches(record, allow)) return false; + if (deny.length > 0 && matches(record, deny)) return false; + return true; + }); +} + +// --------------------------------------------------------------------------- +// Selector resolution (used by vault.select) +// --------------------------------------------------------------------------- + +export type Selector = { id?: string; name?: string; path?: string }; + +/** + * Resolve a selector against the filtered vault list. Returns the matched + * record OR — when the selector is a `path` not present in the list — a + * synthesised ad-hoc record (after validating the path exists as a + * directory). This lets the LLM point at a fresh vault to initialise. + */ +export async function resolveSelector( + selector: Selector, + vaults: VaultRecord[], + platform: NodeJS.Platform = process.platform, +): Promise { + if (selector.id) { + const match = vaults.find((v) => v.id === selector.id); + if (!match) { + throw new AppError("not_found", `No vault with id "${selector.id}"`); + } + return match; + } + if (selector.name) { + const wanted = selector.name.toLowerCase(); + const match = vaults.find((v) => v.name.toLowerCase() === wanted); + if (!match) { + throw new AppError("not_found", `No vault named "${selector.name}"`); + } + return match; + } + if (selector.path) { + if (!path.isAbsolute(selector.path)) { + throw new AppError("invalid_argument", "`path` must be absolute"); + } + try { + const stat = await fs.stat(selector.path); + if (!stat.isDirectory()) { + throw new AppError("invalid_argument", `path is not a directory: ${selector.path}`); + } + } catch (err) { + if (err instanceof AppError) throw err; + throw new AppError("not_found", `path does not exist: ${selector.path}`); + } + + const selectorKey = canonicalPath(selector.path, platform); + const existing = vaults.find((v) => canonicalPath(v.path, platform) === selectorKey); + if (existing) return existing; + + // Ad-hoc record — not in any registered source, but valid on disk. + return { + id: `path:${selectorKey}`, + name: basename(selector.path), + path: path.resolve(selector.path), + isDefault: false, + isActive: false, + source: "env-named", // treat as if it were an env-configured vault + exists: true, + }; + } + throw new AppError("invalid_argument", "selector requires exactly one of id, name, path"); +} + +// --------------------------------------------------------------------------- +// High-level discovery (called by vault.list and on first requireVaultPath +// demand) +// --------------------------------------------------------------------------- + +export type DiscoverResult = { + /** Allow/deny-gated view. This is what vault.list should surface. */ + records: VaultRecord[]; + /** + * Same records before allow/deny was applied. `vault.select` uses this so + * it can distinguish "vault doesn't exist" (not_found) from "vault exists + * but is blocked by operator gating" (unauthorized) — a better error UX + * than silently collapsing both to not_found. + */ + ungatedRecords: VaultRecord[]; + obsidianConfigPath: string | null; + obsidianConfigError?: string; +}; + +export async function discoverVaults( + env: AppEnv, + activeVaultId: string | null, + deps: Partial = {}, +): Promise { + const resolvedDeps: CandidateDeps = { + platform: deps.platform ?? process.platform, + home: deps.home ?? os.homedir(), + env: deps.env ?? (process.env as NodeJS.ProcessEnv), + }; + + let obsidianConfigPath: string | null = null; + let obsidianConfig: ParsedObsidianConfig | null = null; + let obsidianConfigError: string | undefined; + + if (env.KOBSIDIAN_VAULT_DISCOVERY === "on") { + obsidianConfigPath = await resolveObsidianConfigPath(resolvedDeps); + if (obsidianConfigPath) { + const parsed = await parseObsidianConfig(obsidianConfigPath); + if (parsed.ok) { + obsidianConfig = parsed.config; + } else { + obsidianConfigError = parsed.error; + } + } + } + + const merged = mergeVaultSources({ + env, + obsidianConfig, + platform: resolvedDeps.platform, + }); + const ungatedRecords = await finaliseVaultRecords(merged, activeVaultId, resolvedDeps.platform); + const records = applyAllowDeny(ungatedRecords, env, resolvedDeps.platform); + + return { + records, + ungatedRecords, + obsidianConfigPath, + obsidianConfigError, + }; +} diff --git a/src/schema/vaults.ts b/src/schema/vaults.ts new file mode 100644 index 0000000..8c8796b --- /dev/null +++ b/src/schema/vaults.ts @@ -0,0 +1,176 @@ +import { z } from "zod"; + +export const vaultSourceSchema = z + .enum(["obsidian-app", "env-named", "env-default"]) + .describe( + "Where kObsidian learned about this vault. `env-default` = OBSIDIAN_VAULT_PATH; `env-named` = an OBSIDIAN_VAULT_=path env var; `obsidian-app` = parsed from the user's local Obsidian application config (obsidian.json).", + ); +export type VaultSource = z.infer; + +export const vaultRecordSchema = z + .object({ + id: z + .string() + .describe( + "Stable identifier. `obsidian-app` sources use the 16-char hex id Obsidian assigns; `env-named` uses `env:`; `env-default` uses `default`.", + ), + name: z + .string() + .describe( + "Human-readable label. `obsidian-app` + `env-default` sources derive from `path.basename`; `env-named` uses the env-var suffix lowercased.", + ), + path: z.string().describe("Absolute path to the vault directory."), + isDefault: z + .boolean() + .describe("True if this vault matches OBSIDIAN_VAULT_PATH set at server startup."), + isActive: z.boolean().describe("True if this vault is the currently session-selected vault."), + source: vaultSourceSchema, + lastOpened: z + .string() + .optional() + .describe( + "ISO timestamp of when the user last opened this vault in Obsidian. Only present for `obsidian-app` sources.", + ), + exists: z + .boolean() + .describe( + "Whether the path exists on disk at discovery time. `false` flags stale obsidian.json entries (deleted vaults).", + ), + }) + .describe("A single vault entry returned by `vault.list` and `vault.current.active`."); +export type VaultRecord = z.infer; + +export const vaultListArgsSchema = z + .object({ + refresh: z + .boolean() + .optional() + .describe( + "When true, force a fresh scan of the filesystem and obsidian.json even if the cache is warm. Default false.", + ), + }) + .strict() + .describe("Arguments for `vault.list`."); +export type VaultListArgs = z.input; + +export const vaultListOutputSchema = z + .object({ + total: z.number().int().nonnegative(), + items: z.array(vaultRecordSchema), + activeVaultId: z + .string() + .nullable() + .describe("Id of the currently session-selected vault, or null when using the env default."), + obsidianConfigPath: z + .string() + .nullable() + .describe( + "Path to the obsidian.json file kObsidian successfully parsed, or null when no config file was found or discovery is disabled.", + ), + obsidianConfigError: z + .string() + .optional() + .describe( + "Present only when obsidian.json was located but could not be parsed. The string names the failure reason for debuggability.", + ), + }) + .describe("Return shape of `vault.list`."); + +export const vaultCurrentArgsSchema = z + .object({}) + .strict() + .describe("Arguments for `vault.current` (none)."); +export type VaultCurrentArgs = z.input; + +export const vaultCurrentOutputSchema = z + .object({ + active: vaultRecordSchema + .nullable() + .describe( + "The vault filesystem tools would resolve to right now. Null only when neither a session selection nor OBSIDIAN_VAULT_PATH is set.", + ), + reason: z + .enum(["session-selected", "env-default", "none"]) + .describe( + "Why `active` resolved this way: `session-selected` = set by vault.select; `env-default` = fell back to OBSIDIAN_VAULT_PATH; `none` = nothing is configured.", + ), + envDefault: z + .object({ path: z.string() }) + .nullable() + .describe("The OBSIDIAN_VAULT_PATH value at server startup, if any."), + obsidianLiveInstance: z + .object({ + apiUrl: z.string(), + note: z.string(), + }) + .optional() + .describe( + "Present when OBSIDIAN_API_URL is configured. Explains that workspace.* and commands.* tools target the vault the live Obsidian process has open, independent of the filesystem vault selected here.", + ), + }) + .describe("Return shape of `vault.current`."); + +// Selector for vault.select: exactly one of id, name, or path. Enforced via +// .refine() because Zod's discriminatedUnion requires a literal discriminant +// field, which we can't express when the discriminant is "which optional key +// is present". +export const vaultSelectArgsSchema = z + .object({ + id: z + .string() + .min(1) + .optional() + .describe("Vault id returned by `vault.list`. Mutually exclusive with `name` and `path`."), + name: z + .string() + .min(1) + .optional() + .describe( + "Case-insensitive vault name match against `vault.list`. Mutually exclusive with `id` and `path`.", + ), + path: z + .string() + .min(1) + .optional() + .describe( + "Absolute vault directory path. Need not appear in `vault.list` — accepting an ad-hoc path lets the LLM point at a fresh vault. Must exist and be a directory. Mutually exclusive with `id` and `name`.", + ), + }) + .strict() + .refine( + (value) => + [value.id, value.name, value.path].filter((v) => typeof v === "string" && v.length > 0) + .length === 1, + { + message: "Provide exactly one of `id`, `name`, or `path`", + }, + ) + .describe("Arguments for `vault.select`."); +export type VaultSelectArgs = z.input; + +export const vaultSelectOutputSchema = z + .object({ + changed: z.boolean(), + target: z.string(), + summary: z.string(), + active: vaultRecordSchema, + previous: vaultRecordSchema + .nullable() + .describe("The previously-selected vault, or null if none was selected."), + }) + .describe("Return shape of `vault.select`."); + +export const vaultResetArgsSchema = z + .object({}) + .strict() + .describe("Arguments for `vault.reset` (none)."); +export type VaultResetArgs = z.input; + +export const vaultResetOutputSchema = z + .object({ + changed: z.boolean(), + target: z.string(), + summary: z.string(), + previous: vaultRecordSchema.nullable(), + }) + .describe("Return shape of `vault.reset`."); diff --git a/src/server/create-server.ts b/src/server/create-server.ts index 280af6b..9e852fa 100644 --- a/src/server/create-server.ts +++ b/src/server/create-server.ts @@ -9,9 +9,54 @@ import { toolRegistry } from "./registry.js"; import { registerWikiResources } from "./resources.js"; import type { ToolDefinition } from "./tool-definition.js"; +// Filesystem-scoped tool namespaces. Any tool whose name starts with one of +// these gets a session-active-vault note appended to its description at +// registration time, so the LLM knows the precedence chain (per-call +// vaultPath > vault.select > OBSIDIAN_VAULT_PATH) without us having to +// hand-edit every tool description. +const FILESYSTEM_NAMESPACES = [ + "notes.", + "tags.", + "dataview.", + "blocks.", + "canvas.", + "kanban.", + "marp.", + "templates.", + "tasks.", + "links.", + "wiki.", + "stats.vault", +]; + +const SESSION_VAULT_NOTE = + "Operates on the session-active vault (see `vault.current` — selectable via `vault.select`) unless an explicit `vaultPath` argument is passed, which always wins."; + +// Tools that bridge to the live Obsidian process via the Local REST API. +// They target whichever vault Obsidian itself has open, and are NOT affected +// by vault.select (which only changes filesystem-tool routing). +const LIVE_OBSIDIAN_NAMESPACES = ["workspace.", "commands."]; + +const LIVE_OBSIDIAN_NOTE = + "Targets the vault the live Obsidian process has open via the Local REST API. Not affected by `vault.select` — that only changes filesystem-tool routing."; + +function targetsFilesystemVault(name: string): boolean { + return FILESYSTEM_NAMESPACES.some((prefix) => name.startsWith(prefix)); +} + +function targetsLiveObsidian(name: string): boolean { + return LIVE_OBSIDIAN_NAMESPACES.some((prefix) => name.startsWith(prefix)); +} + function buildDescription(tool: ToolDefinition): string { + let description = tool.description; + if (targetsFilesystemVault(tool.name)) { + description = `${description}\n\n${SESSION_VAULT_NOTE}`; + } else if (targetsLiveObsidian(tool.name)) { + description = `${description}\n\n${LIVE_OBSIDIAN_NOTE}`; + } if (!tool.inputExamples || tool.inputExamples.length === 0) { - return tool.description; + return description; } const examples = tool.inputExamples .map( @@ -19,7 +64,7 @@ function buildDescription(tool: ToolDefinition): string { `Example ${i + 1} — ${ex.description}:\n\`\`\`json\n${JSON.stringify(ex.input, null, 2)}\n\`\`\``, ) .join("\n\n"); - return `${tool.description}\n\nExamples:\n\n${examples}`; + return `${description}\n\nExamples:\n\n${examples}`; } function getSummary(result: unknown): string | undefined { diff --git a/src/server/prompts.ts b/src/server/prompts.ts index de3f04e..201062e 100644 --- a/src/server/prompts.ts +++ b/src/server/prompts.ts @@ -49,8 +49,8 @@ export function registerWikiPrompts(server: McpServer): void { "", "Step 2 — iterate the returned `proposedEdits` array. For each proposal:", `- \`operation: ${opCreateStub}\` → use \`notes.create\` with the \`suggestedContent\`.`, - `- \`operation: ${opInsertAfterHeading}\` → use \`notes.insertAfterHeading\` with the given \`heading\`.`, - `- \`operation: ${opAppend}\` → use \`notes.append\`.`, + `- \`operation: ${opInsertAfterHeading}\` → use \`notes.edit\` with \`mode: 'after-heading'\`, \`anchor: \`, and the given \`suggestedContent\`.`, + `- \`operation: ${opAppend}\` → use \`notes.edit\` with \`mode: 'append'\`.`, "", "Apply index edits directly. For stub creations of Entities, consider whether to refine `kind` from `other` to `person/place/org/work` based on context.", "", @@ -144,4 +144,41 @@ export function registerWikiPrompts(server: McpServer): void { }; }, ); + + server.registerPrompt( + "pick-vault", + { + title: "Pick An Obsidian Vault", + description: + "Prompt template that instructs the agent to discover the user's known Obsidian vaults, present them, and select one for the session using vault.select.", + argsSchema: { + hint: z + .string() + .optional() + .describe( + "Optional hint about which vault to pick, e.g. 'the work one' or 'Personal'. If omitted the agent presents the full list and asks.", + ), + }, + }, + (args) => { + const hint = args.hint?.trim(); + const body = [ + "Help the user pick an Obsidian vault for this session.", + "", + "Step 1 — call `vault.list` to get the known vaults. Each item carries `{id, name, path, isDefault, isActive, source, lastOpened?, exists}`.", + "", + hint + ? `Step 2 — the user hinted: "${hint}". Match it case-insensitively against the \`name\` field first, then against basename(path) as a fallback. If there's a clean single match, proceed to step 3 using that vault's \`name\`. If there are zero or multiple matches, present the candidates and ask the user to clarify.` + : "Step 2 — present the list grouped by `source` (env-default, env-named, obsidian-app). Surface the `isActive` / `isDefault` markers, and show `lastOpened` for obsidian-app entries so the user can see which they've touched recently. Ask the user which one to use.", + "", + "Step 3 — call `vault.select` with `{name: ''}`. Confirm the switch by echoing the new `active.path`.", + "", + "Step 4 — remind the user briefly: this selection affects filesystem tools (notes.*, tags.*, dataview.*, blocks.*, canvas.*, kanban.*, marp.*, templates.*, tasks.*, links.*, wiki.*, stats.vault). It does NOT change what the live Obsidian process has open, so workspace.* and commands.* will still target whatever vault Obsidian itself is on.", + ].join("\n"); + return { + description: hint ? `Pick a vault matching "${hint}".` : "Pick an Obsidian vault.", + messages: [textMessage("user", body)], + }; + }, + ); } diff --git a/src/server/registry.ts b/src/server/registry.ts index 25f5a0a..23f76c7 100644 --- a/src/server/registry.ts +++ b/src/server/registry.ts @@ -12,9 +12,11 @@ import { systemTools } from "./tools/system.js"; import { tagTools } from "./tools/tags.js"; import { taskTools } from "./tools/tasks.js"; import { templateTools } from "./tools/templates.js"; +import { vaultTools } from "./tools/vaults.js"; import { wikiTools } from "./tools/wiki.js"; export const toolRegistry: ToolDefinition[] = [ + ...vaultTools, ...noteTools, ...tagTools, ...linkTools, diff --git a/src/server/tools/vaults.ts b/src/server/tools/vaults.ts new file mode 100644 index 0000000..abba0ff --- /dev/null +++ b/src/server/tools/vaults.ts @@ -0,0 +1,180 @@ +import type { DomainContext } from "../../domain/context.js"; +import { applyAllowDeny, discoverVaults, resolveSelector } from "../../domain/vaults.js"; +import { AppError } from "../../lib/errors.js"; +import { + type VaultCurrentArgs, + type VaultListArgs, + type VaultRecord, + type VaultResetArgs, + type VaultSelectArgs, + vaultCurrentArgsSchema, + vaultCurrentOutputSchema, + vaultListArgsSchema, + vaultListOutputSchema, + vaultResetArgsSchema, + vaultResetOutputSchema, + vaultSelectArgsSchema, + vaultSelectOutputSchema, +} from "../../schema/vaults.js"; +import type { ToolDefinition } from "../tool-definition.js"; +import { ADDITIVE, IDEMPOTENT_ADDITIVE, READ_ONLY } from "../tool-schemas.js"; + +const CACHE_TTL_MS = 30_000; + +async function ensureDiscovered(context: DomainContext, refresh = false) { + const now = Date.now(); + if (refresh || !context.vaults.result || now - context.vaults.lastRefreshedAt > CACHE_TTL_MS) { + const result = await discoverVaults(context.env, context.session.activeVault?.id ?? null); + context.vaults.lastRefreshedAt = now; + context.vaults.result = result; + } + return context.vaults.result; +} + +function obsidianLiveInstance(context: DomainContext) { + if (!context.env.OBSIDIAN_API_URL || !context.api.isConfigured) return undefined; + return { + apiUrl: context.env.OBSIDIAN_API_URL, + note: "workspace.* and commands.* tools operate on the vault the live Obsidian process has open, independent of the filesystem vault selected by vault.select.", + }; +} + +function activeRecord( + context: DomainContext, + records: VaultRecord[], +): { + active: VaultRecord | null; + reason: "session-selected" | "env-default" | "none"; +} { + if (context.session.activeVault) { + return { active: context.session.activeVault, reason: "session-selected" }; + } + const defaultPath = context.env.OBSIDIAN_VAULT_PATH; + if (defaultPath) { + const found = records.find((r) => r.isDefault) ?? null; + return { active: found, reason: "env-default" }; + } + return { active: null, reason: "none" }; +} + +export const vaultTools: ToolDefinition[] = [ + { + name: "vault.list", + title: "List Known Vaults", + description: + "List every Obsidian vault kObsidian knows about, merged and deduplicated across three sources: the operator's OBSIDIAN_VAULT_PATH (the default — always included), any OBSIDIAN_VAULT_=path env vars (explicit named vaults), and — when KOBSIDIAN_VAULT_DISCOVERY is `on` (the default) — the user's local Obsidian application registry at obsidian.json. Each item reports its `source`, `isDefault`, `isActive`, and `exists` so the LLM can flag stale or missing vaults. Pass `refresh: true` to force a fresh scan instead of using the 30s cache. Read-only. NOTE: the `obsidian-app` source is EXPERIMENTAL — it parses Obsidian's undocumented obsidian.json registry (stable since 1.0 but internal to Obsidian) and may silently stop returning results if Obsidian changes the format; the env-var sources are the documented, stable path.", + inputSchema: vaultListArgsSchema, + outputSchema: vaultListOutputSchema, + annotations: READ_ONLY, + handler: async (context, rawArgs) => { + const args = vaultListArgsSchema.parse(rawArgs) as VaultListArgs; + const result = await ensureDiscovered(context, args.refresh === true); + return { + total: result.records.length, + items: result.records, + activeVaultId: context.session.activeVault?.id ?? null, + obsidianConfigPath: result.obsidianConfigPath, + ...(result.obsidianConfigError ? { obsidianConfigError: result.obsidianConfigError } : {}), + }; + }, + inputExamples: [ + { description: "List vaults using the 30-second cache", input: {} }, + { + description: "Force a rescan (obsidian.json changed, new env vars added)", + input: { refresh: true }, + }, + ], + }, + { + name: "vault.current", + title: "Current Active Vault", + description: + "Return the vault that filesystem tools (notes.*, tags.*, dataview.*, blocks.*, canvas.*, kanban.*, marp.*, templates.*, tasks.*, links.*, wiki.*, stats.vault) would resolve to right now, plus the full precedence chain so the LLM can explain to the user why that vault was picked. `reason` is `session-selected` (vault.select was called), `env-default` (fell back to OBSIDIAN_VAULT_PATH), or `none` (nothing configured — tools will fail until vault.select or an env var is set). When OBSIDIAN_API_URL is configured, the response also carries an `obsidianLiveInstance` note reminding the caller that workspace.* and commands.* tools target whichever vault the live Obsidian process has open, NOT the filesystem vault selected here. Read-only.", + inputSchema: vaultCurrentArgsSchema, + outputSchema: vaultCurrentOutputSchema, + annotations: READ_ONLY, + handler: async (context, rawArgs) => { + vaultCurrentArgsSchema.parse(rawArgs) as VaultCurrentArgs; + const result = await ensureDiscovered(context); + const { active, reason } = activeRecord(context, result.records); + return { + active, + reason, + envDefault: context.env.OBSIDIAN_VAULT_PATH + ? { path: context.env.OBSIDIAN_VAULT_PATH } + : null, + ...(obsidianLiveInstance(context) + ? { obsidianLiveInstance: obsidianLiveInstance(context) } + : {}), + }; + }, + }, + { + name: "vault.select", + title: "Select Active Vault", + description: + "Set the session-active vault for subsequent filesystem tool calls. Identify the target by EXACTLY ONE of `id` (stable id from vault.list), `name` (case-insensitive match), or `path` (absolute directory path — need not appear in vault.list; lets the LLM point at a fresh/empty vault to initialise). Precedence chain becomes: per-call `vaultPath` argument (highest) → this session selection → OBSIDIAN_VAULT_PATH → error. Explicit `vaultPath` arguments on individual tool calls always override this selection. Respects KOBSIDIAN_VAULT_ALLOW / KOBSIDIAN_VAULT_DENY operator gating (though OBSIDIAN_VAULT_PATH is never filtered). Does NOT change which vault the live Obsidian process has open — `workspace.*` and `commands.*` tools remain tied to OBSIDIAN_API_URL. HTTP deployments: this server shares the selection across HTTP clients, so concurrent multi-client HTTP setups should pass `vaultPath` per call instead.", + inputSchema: vaultSelectArgsSchema, + outputSchema: vaultSelectOutputSchema, + annotations: ADDITIVE, + handler: async (context, rawArgs) => { + const args = vaultSelectArgsSchema.parse(rawArgs) as VaultSelectArgs; + const discovered = await ensureDiscovered(context); + // Resolve against the UNGATED pool so we can distinguish + // "vault exists but is blocked by operator gating" (unauthorized) from + // "vault doesn't exist" (not_found) — better error UX than collapsing + // both to not_found. + const candidateRecord = await resolveSelector( + { id: args.id, name: args.name, path: args.path }, + discovered.ungatedRecords, + ); + const [gated] = applyAllowDeny([candidateRecord], context.env); + if (!gated) { + throw new AppError( + "unauthorized", + `Vault "${candidateRecord.name}" is blocked by KOBSIDIAN_VAULT_ALLOW / KOBSIDIAN_VAULT_DENY.`, + ); + } + const previous = context.session.activeVault; + const next: VaultRecord = { ...gated, isActive: true }; + context.session.activeVault = next; + return { + changed: previous?.path !== next.path, + target: next.path, + summary: `Active vault set to "${next.name}" (${next.path})`, + active: next, + previous, + }; + }, + inputExamples: [ + { description: "Switch to the vault named 'Work'", input: { name: "Work" } }, + { description: "Select by id from vault.list", input: { id: "58f115bd2c2febd2" } }, + { + description: "Point at an ad-hoc path (e.g. a fresh vault to initialise)", + input: { path: "/Users/alice/FreshVault" }, + }, + ], + }, + { + name: "vault.reset", + title: "Reset Active Vault Selection", + description: + "Clear the session-selected vault so the precedence chain falls back to OBSIDIAN_VAULT_PATH. Use this to signal 'I'm done with the scratch vault, go back to the default'. Idempotent — running on an already-cleared session is a no-op that reports `changed: false`. Does not change per-call `vaultPath` behaviour.", + inputSchema: vaultResetArgsSchema, + outputSchema: vaultResetOutputSchema, + annotations: IDEMPOTENT_ADDITIVE, + handler: async (context, rawArgs) => { + vaultResetArgsSchema.parse(rawArgs) as VaultResetArgs; + const previous = context.session.activeVault; + context.session.activeVault = null; + return { + changed: previous !== null, + target: context.env.OBSIDIAN_VAULT_PATH ?? "", + summary: previous + ? `Active vault selection cleared (was "${previous.name}")` + : "No active vault selection to clear", + previous, + }; + }, + }, +]; diff --git a/tests/fixtures/obsidian/empty-vaults.json b/tests/fixtures/obsidian/empty-vaults.json new file mode 100644 index 0000000..6f97d74 --- /dev/null +++ b/tests/fixtures/obsidian/empty-vaults.json @@ -0,0 +1 @@ +{ "vaults": {} } diff --git a/tests/fixtures/obsidian/linux-real.json b/tests/fixtures/obsidian/linux-real.json new file mode 100644 index 0000000..bc4e314 --- /dev/null +++ b/tests/fixtures/obsidian/linux-real.json @@ -0,0 +1,13 @@ +{ + "vaults": { + "aa11bb22cc33dd44": { + "path": "/home/carol/vaults/personal", + "ts": 1776000000000, + "open": true + }, + "ee55ff66aa77bb88": { + "path": "/home/carol/vaults/research", + "ts": 1775500000000 + } + } +} diff --git a/tests/fixtures/obsidian/macos-real.json b/tests/fixtures/obsidian/macos-real.json new file mode 100644 index 0000000..9e93ae3 --- /dev/null +++ b/tests/fixtures/obsidian/macos-real.json @@ -0,0 +1,14 @@ +{ + "vaults": { + "a1b2c3d4e5f60708": { + "path": "/Users/alice/Documents/Personal", + "ts": 1776000000000, + "open": true + }, + "1234567890abcdef": { + "path": "/Users/alice/Work/notes", + "ts": 1775000000000 + } + }, + "updateVersion": "1.8.10" +} diff --git a/tests/fixtures/obsidian/malformed.json b/tests/fixtures/obsidian/malformed.json new file mode 100644 index 0000000..8af9cea --- /dev/null +++ b/tests/fixtures/obsidian/malformed.json @@ -0,0 +1 @@ +{ "vaults": { "abc": { "path": "/tmp/v", "ts": 1 }, <-- malformed JSON diff --git a/tests/fixtures/obsidian/no-vaults.json b/tests/fixtures/obsidian/no-vaults.json new file mode 100644 index 0000000..3224118 --- /dev/null +++ b/tests/fixtures/obsidian/no-vaults.json @@ -0,0 +1,4 @@ +{ + "updateVersion": "1.8.10", + "promoted": "community-plugins" +} diff --git a/tests/fixtures/obsidian/windows-real.json b/tests/fixtures/obsidian/windows-real.json new file mode 100644 index 0000000..08f678a --- /dev/null +++ b/tests/fixtures/obsidian/windows-real.json @@ -0,0 +1,14 @@ +{ + "vaults": { + "58f115bd2c2febd2": { + "path": "C:\\Users\\Gaming\\Documents\\gansai", + "ts": 1776085053677, + "open": true + }, + "c0ffee00deadbeef": { + "path": "D:\\Notes\\Work", + "ts": 1775000000000 + } + }, + "updateVersion": "1.8.10" +} diff --git a/tests/fixtures/obsidian/with-unknown-keys.json b/tests/fixtures/obsidian/with-unknown-keys.json new file mode 100644 index 0000000..6ba5fc2 --- /dev/null +++ b/tests/fixtures/obsidian/with-unknown-keys.json @@ -0,0 +1,13 @@ +{ + "vaults": { + "abc123def456abcd": { + "path": "/Users/dan/Obsidian/Journal", + "ts": 1776100000000, + "open": true, + "unknownPerVaultField": "ignore me" + } + }, + "updateVersion": "1.8.10", + "promoted": "community-plugins", + "someFutureField": { "nested": true } +} diff --git a/tests/vault-discovery.test.ts b/tests/vault-discovery.test.ts new file mode 100644 index 0000000..71d1acf --- /dev/null +++ b/tests/vault-discovery.test.ts @@ -0,0 +1,376 @@ +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { getEnv } from "../src/config/env.js"; +import { + applyAllowDeny, + mergeVaultSources, + parseObsidianConfig, + resolveSelector, +} from "../src/domain/vaults.js"; +import type { VaultRecord } from "../src/schema/vaults.js"; + +const fixturesRoot = path.resolve(process.cwd(), "tests", "fixtures", "obsidian"); + +function fx(name: string): string { + return path.join(fixturesRoot, name); +} + +function buildEnv(overrides: Record = {}) { + return getEnv({ + ...process.env, + // Clear vars that would otherwise leak from the host env and pollute + // expectations. This is important because process.env on the maintainer's + // Windows machine carries real OBSIDIAN_VAULT_PATH / named vars. + OBSIDIAN_VAULT_PATH: undefined, + KOBSIDIAN_VAULT_ALLOW: undefined, + KOBSIDIAN_VAULT_DENY: undefined, + KOBSIDIAN_VAULT_DISCOVERY: "on", + KOBSIDIAN_ALLOWED_ORIGINS: "http://localhost", + ...overrides, + // Strip any host-set OBSIDIAN_VAULT_* that would sneak into namedVaults. + ...Object.fromEntries( + Object.keys(process.env) + .filter((k) => k.startsWith("OBSIDIAN_VAULT_") && k !== "OBSIDIAN_VAULT_PATH") + .map((k) => [k, undefined]), + ), + } as NodeJS.ProcessEnv); +} + +// --------------------------------------------------------------------------- +// parseObsidianConfig — fixture round-trip +// --------------------------------------------------------------------------- + +describe("parseObsidianConfig", () => { + it("parses the macOS fixture", async () => { + const result = await parseObsidianConfig(fx("macos-real.json")); + expect(result.ok).toBe(true); + if (result.ok) { + expect(Object.keys(result.config.vaults ?? {})).toHaveLength(2); + expect(result.config.vaults?.a1b2c3d4e5f60708?.path).toBe("/Users/alice/Documents/Personal"); + } + }); + + it("parses the Windows fixture with escaped backslashes", async () => { + const result = await parseObsidianConfig(fx("windows-real.json")); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.vaults?.["58f115bd2c2febd2"]?.path).toBe( + "C:\\Users\\Gaming\\Documents\\gansai", + ); + } + }); + + it("parses the Linux fixture", async () => { + const result = await parseObsidianConfig(fx("linux-real.json")); + expect(result.ok).toBe(true); + }); + + it("handles valid JSON with no `vaults` key", async () => { + const result = await parseObsidianConfig(fx("no-vaults.json")); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.vaults).toBeUndefined(); + } + }); + + it("handles an empty `vaults` object", async () => { + const result = await parseObsidianConfig(fx("empty-vaults.json")); + expect(result.ok).toBe(true); + if (result.ok) { + expect(Object.keys(result.config.vaults ?? {})).toHaveLength(0); + } + }); + + it("tolerates unknown top-level + per-vault keys", async () => { + const result = await parseObsidianConfig(fx("with-unknown-keys.json")); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.vaults?.abc123def456abcd?.path).toBe("/Users/dan/Obsidian/Journal"); + } + }); + + it("returns a structured error on malformed JSON, never throws", async () => { + const result = await parseObsidianConfig(fx("malformed.json")); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("JSON parse failed"); + } + }); + + it("returns a structured error on a missing file", async () => { + const result = await parseObsidianConfig(fx("does-not-exist.json")); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("read failed"); + } + }); +}); + +// --------------------------------------------------------------------------- +// mergeVaultSources — precedence, dedup, lastOpened preservation +// --------------------------------------------------------------------------- + +describe("mergeVaultSources", () => { + it("produces no records when no sources are set", () => { + const out = mergeVaultSources({ + env: buildEnv(), + obsidianConfig: null, + platform: "linux", + }); + expect(out).toHaveLength(0); + }); + + it("env-default alone produces one record with id='default'", () => { + const out = mergeVaultSources({ + env: buildEnv({ OBSIDIAN_VAULT_PATH: "/vaults/main" }), + obsidianConfig: null, + platform: "linux", + }); + expect(out).toEqual([ + expect.objectContaining({ + id: "default", + name: "main", + path: "/vaults/main", + source: "env-default", + isDefault: true, + }), + ]); + }); + + it("named env vars surface as `env:` records", () => { + const out = mergeVaultSources({ + env: buildEnv({ + OBSIDIAN_VAULT_WORK: "/vaults/work", + OBSIDIAN_VAULT_PERSONAL: "/vaults/personal", + }), + obsidianConfig: null, + platform: "linux", + }); + const ids = out.map((r) => r.id).sort(); + expect(ids).toEqual(["env:personal", "env:work"]); + }); + + it("merges obsidian.json entries with env entries", () => { + const out = mergeVaultSources({ + env: buildEnv({ OBSIDIAN_VAULT_PATH: "/vaults/default" }), + obsidianConfig: { + vaults: { + hex1: { path: "/vaults/other", ts: 1000 }, + }, + }, + platform: "linux", + }); + expect(out).toHaveLength(2); + const byId = new Map(out.map((r) => [r.id, r])); + expect(byId.get("default")?.isDefault).toBe(true); + expect(byId.get("hex1")?.source).toBe("obsidian-app"); + expect(byId.get("hex1")?.lastOpened).toBe(new Date(1000).toISOString()); + }); + + it("dedupes by canonical path, preferring env-default > env-named > obsidian-app", () => { + const sharedPath = "/vaults/shared"; + const out = mergeVaultSources({ + env: buildEnv({ + OBSIDIAN_VAULT_PATH: sharedPath, + OBSIDIAN_VAULT_ALIAS: sharedPath, + }), + obsidianConfig: { + vaults: { + hex1: { path: sharedPath, ts: 5000 }, + }, + }, + platform: "linux", + }); + expect(out).toHaveLength(1); + expect(out[0]?.id).toBe("default"); // env-default wins + expect(out[0]?.source).toBe("env-default"); + // But lastOpened from the obsidian-app sibling is preserved. + expect(out[0]?.lastOpened).toBe(new Date(5000).toISOString()); + }); + + it("case-folds paths on Windows for dedup", () => { + const out = mergeVaultSources({ + env: buildEnv({ OBSIDIAN_VAULT_PATH: "C:\\Users\\Me\\Vault" }), + obsidianConfig: { + vaults: { + hex1: { path: "c:\\users\\me\\vault", ts: 1 }, + }, + }, + platform: "win32", + }); + expect(out).toHaveLength(1); + expect(out[0]?.source).toBe("env-default"); + }); + + it("does NOT case-fold on POSIX", () => { + const out = mergeVaultSources({ + env: buildEnv({ OBSIDIAN_VAULT_PATH: "/Vaults/Main" }), + obsidianConfig: { + vaults: { + hex1: { path: "/vaults/main", ts: 1 }, + }, + }, + platform: "linux", + }); + expect(out).toHaveLength(2); // different paths on case-sensitive FS + }); + + it("omits lastOpened when ts is absent", () => { + const out = mergeVaultSources({ + env: buildEnv(), + obsidianConfig: { vaults: { hex1: { path: "/v" } } }, + platform: "linux", + }); + expect(out[0]?.lastOpened).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// applyAllowDeny — allowlist/denylist with the OBSIDIAN_VAULT_PATH invariant +// --------------------------------------------------------------------------- + +function makeRecord(overrides: Partial): VaultRecord { + return { + id: overrides.id ?? "default", + name: overrides.name ?? "default", + path: overrides.path ?? "/v", + isDefault: overrides.isDefault ?? false, + isActive: overrides.isActive ?? false, + source: overrides.source ?? "env-default", + exists: overrides.exists ?? true, + lastOpened: overrides.lastOpened, + }; +} + +describe("applyAllowDeny", () => { + it("passes through when both lists are empty", () => { + const env = buildEnv(); + const records = [makeRecord({ path: "/a", name: "a" })]; + expect(applyAllowDeny(records, env, "linux")).toEqual(records); + }); + + it("allowlist filters by name (case-insensitive)", () => { + const env = buildEnv({ KOBSIDIAN_VAULT_ALLOW: "Work,Personal" }); + const records = [ + makeRecord({ id: "1", name: "work", path: "/vaults/work" }), + makeRecord({ id: "2", name: "scratch", path: "/vaults/scratch" }), + ]; + const out = applyAllowDeny(records, env, "linux"); + expect(out.map((r) => r.name)).toEqual(["work"]); + }); + + it("allowlist filters by absolute path", () => { + const env = buildEnv({ KOBSIDIAN_VAULT_ALLOW: "/vaults/work" }); + const records = [ + makeRecord({ id: "1", name: "work", path: "/vaults/work" }), + makeRecord({ id: "2", name: "scratch", path: "/vaults/scratch" }), + ]; + const out = applyAllowDeny(records, env, "linux"); + expect(out.map((r) => r.name)).toEqual(["work"]); + }); + + it("denylist removes matching entries", () => { + const env = buildEnv({ KOBSIDIAN_VAULT_DENY: "secrets" }); + const records = [ + makeRecord({ id: "1", name: "work", path: "/vaults/work" }), + makeRecord({ id: "2", name: "secrets", path: "/vaults/secrets" }), + ]; + const out = applyAllowDeny(records, env, "linux"); + expect(out.map((r) => r.name)).toEqual(["work"]); + }); + + it("OBSIDIAN_VAULT_PATH is never filtered out even when denied explicitly", () => { + const env = buildEnv({ + OBSIDIAN_VAULT_PATH: "/vaults/main", + KOBSIDIAN_VAULT_DENY: "main,/vaults/main", + }); + const records = [ + makeRecord({ + id: "default", + name: "main", + path: "/vaults/main", + isDefault: true, + }), + ]; + const out = applyAllowDeny(records, env, "linux"); + expect(out).toHaveLength(1); + }); + + it("OBSIDIAN_VAULT_PATH survives even when missing from allowlist", () => { + const env = buildEnv({ + OBSIDIAN_VAULT_PATH: "/vaults/main", + KOBSIDIAN_VAULT_ALLOW: "work", + }); + const records = [ + makeRecord({ + id: "default", + name: "main", + path: "/vaults/main", + isDefault: true, + }), + makeRecord({ id: "1", name: "work", path: "/vaults/work" }), + makeRecord({ id: "2", name: "other", path: "/vaults/other" }), + ]; + const out = applyAllowDeny(records, env, "linux"); + const names = out.map((r) => r.name).sort(); + expect(names).toEqual(["main", "work"]); + }); +}); + +// --------------------------------------------------------------------------- +// resolveSelector — id / name / path branches + ad-hoc-path support +// --------------------------------------------------------------------------- + +describe("resolveSelector", () => { + it("finds by id", async () => { + const vaults = [ + makeRecord({ id: "hex1", name: "a", path: "/a" }), + makeRecord({ id: "hex2", name: "b", path: "/b" }), + ]; + const out = await resolveSelector({ id: "hex2" }, vaults, "linux"); + expect(out.path).toBe("/b"); + }); + + it("returns not_found when id is unknown", async () => { + await expect(resolveSelector({ id: "missing" }, [], "linux")).rejects.toMatchObject({ + code: "not_found", + }); + }); + + it("finds by name (case-insensitive)", async () => { + const vaults = [makeRecord({ id: "h", name: "Personal", path: "/p" })]; + const out = await resolveSelector({ name: "personal" }, vaults, "linux"); + expect(out.path).toBe("/p"); + }); + + it("rejects non-absolute path", async () => { + await expect(resolveSelector({ path: "relative/path" }, [], "linux")).rejects.toThrow( + /must be absolute/, + ); + }); + + it("rejects path to a file (not a directory)", async () => { + const fixture = fx("macos-real.json"); // exists but is a file, not a dir + await expect(resolveSelector({ path: fixture }, [], "linux")).rejects.toThrow( + /not a directory/, + ); + }); + + it("returns existing record when path matches one in the list", async () => { + const fixture = fixturesRoot; // the fixtures dir exists + const vaults = [makeRecord({ id: "hex1", name: "fixtures", path: fixture, isDefault: true })]; + const out = await resolveSelector({ path: fixture }, vaults, process.platform); + expect(out.id).toBe("hex1"); // returned the existing record, not a synthesised one + }); + + it("synthesises an ad-hoc record when the path isn't registered", async () => { + const out = await resolveSelector( + { path: fixturesRoot }, + [], // empty registry + process.platform, + ); + expect(out.path).toBe(path.resolve(fixturesRoot)); + expect(out.id).toMatch(/^path:/); + expect(out.exists).toBe(true); + }); +}); diff --git a/tests/vault-paths.test.ts b/tests/vault-paths.test.ts new file mode 100644 index 0000000..e1be79c --- /dev/null +++ b/tests/vault-paths.test.ts @@ -0,0 +1,135 @@ +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { obsidianConfigCandidates } from "../src/domain/vaults.js"; + +// Path.join uses OS-native separators (\ on Windows, / on POSIX). The tests +// feed mocked platforms into obsidianConfigCandidates, but the implementation +// under test still calls Node's path.join against the host runner's OS. We +// compose expectations via path.join too so the tests work identically on +// Windows, macOS, and Linux runners. +const J = path.join; + +// These tests verify the platform-path resolution logic by feeding +// mocked `{platform, home, env}` directly into `obsidianConfigCandidates`. +// That avoids the need for actual macOS / Linux runners to prove the +// paths are correct — the real CI matrix (ci.yml) then confirms the +// resulting code still compiles + runs on each OS. + +const macHome = "/Users/alice"; +const linuxHome = "/home/carol"; +const winHome = "C:\\Users\\Gaming"; + +describe("obsidianConfigCandidates — macOS", () => { + it("returns the Application Support path", () => { + const out = obsidianConfigCandidates({ + platform: "darwin", + home: macHome, + env: {}, + }); + expect(out).toEqual([J(macHome, "Library/Application Support/obsidian/obsidian.json")]); + }); + + it("ignores XDG_CONFIG_HOME on darwin (XDG is Linux-only here)", () => { + const out = obsidianConfigCandidates({ + platform: "darwin", + home: macHome, + env: { XDG_CONFIG_HOME: "/irrelevant" }, + }); + expect(out).toEqual([J(macHome, "Library/Application Support/obsidian/obsidian.json")]); + }); +}); + +describe("obsidianConfigCandidates — Windows", () => { + it("uses APPDATA when set", () => { + const out = obsidianConfigCandidates({ + platform: "win32", + home: winHome, + env: { APPDATA: "C:\\Users\\Gaming\\AppData\\Roaming" }, + }); + expect(out).toHaveLength(1); + // node's path.join on win32 produces backslash-separated output when + // running on Windows, and POSIX slashes when running on Linux/mac. + // Don't assert the exact slash style — assert the suffix only. + expect( + out[0]?.endsWith("obsidian/obsidian.json") || out[0]?.endsWith("obsidian\\obsidian.json"), + ).toBe(true); + expect(out[0]).toContain("AppData"); + expect(out[0]).toContain("Roaming"); + }); + + it("falls back to home/AppData/Roaming when APPDATA is unset", () => { + const out = obsidianConfigCandidates({ + platform: "win32", + home: winHome, + env: {}, + }); + expect(out).toHaveLength(1); + expect(out[0]).toContain("AppData"); + expect(out[0]).toContain("Roaming"); + expect(out[0]).toContain("Gaming"); + }); +}); + +describe("obsidianConfigCandidates — Linux", () => { + it("prefers XDG_CONFIG_HOME when set, then falls back to ~/.config then flatpak then snap", () => { + const out = obsidianConfigCandidates({ + platform: "linux", + home: linuxHome, + env: { XDG_CONFIG_HOME: "/xdg/config" }, + }); + expect(out).toEqual([ + J("/xdg/config", "obsidian/obsidian.json"), + J(linuxHome, ".config/obsidian/obsidian.json"), + J(linuxHome, ".var/app/md.obsidian.Obsidian/config/obsidian/obsidian.json"), + J(linuxHome, "snap/obsidian/current/.config/obsidian/obsidian.json"), + ]); + }); + + it("omits the XDG entry when XDG_CONFIG_HOME is unset", () => { + const out = obsidianConfigCandidates({ + platform: "linux", + home: linuxHome, + env: {}, + }); + expect(out).toEqual([ + J(linuxHome, ".config/obsidian/obsidian.json"), + J(linuxHome, ".var/app/md.obsidian.Obsidian/config/obsidian/obsidian.json"), + J(linuxHome, "snap/obsidian/current/.config/obsidian/obsidian.json"), + ]); + }); + + it("omits empty XDG_CONFIG_HOME", () => { + const out = obsidianConfigCandidates({ + platform: "linux", + home: linuxHome, + env: { XDG_CONFIG_HOME: "" }, + }); + expect(out).toEqual([ + J(linuxHome, ".config/obsidian/obsidian.json"), + J(linuxHome, ".var/app/md.obsidian.Obsidian/config/obsidian/obsidian.json"), + J(linuxHome, "snap/obsidian/current/.config/obsidian/obsidian.json"), + ]); + }); +}); + +describe("obsidianConfigCandidates — escape hatch", () => { + it("KOBSIDIAN_OBSIDIAN_CONFIG overrides everything on every platform", () => { + for (const platform of ["darwin", "win32", "linux"] as const) { + const out = obsidianConfigCandidates({ + platform, + home: "/irrelevant", + env: { KOBSIDIAN_OBSIDIAN_CONFIG: "/explicit/path/obsidian.json" }, + }); + expect(out).toEqual(["/explicit/path/obsidian.json"]); + } + }); + + it("ignores empty-string override", () => { + const out = obsidianConfigCandidates({ + platform: "darwin", + home: macHome, + env: { KOBSIDIAN_OBSIDIAN_CONFIG: "" }, + }); + expect(out).toEqual([J(macHome, "Library/Application Support/obsidian/obsidian.json")]); + }); +}); diff --git a/tests/vault-precedence.test.ts b/tests/vault-precedence.test.ts new file mode 100644 index 0000000..718bc8c --- /dev/null +++ b/tests/vault-precedence.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { getEnv } from "../src/config/env.js"; +import { createDomainContext, requireVaultPath } from "../src/domain/context.js"; +import type { VaultRecord } from "../src/schema/vaults.js"; + +function ctx(envPath: string | undefined, sessionPath: string | null) { + const env = getEnv({ + ...process.env, + OBSIDIAN_VAULT_PATH: envPath, + KOBSIDIAN_ALLOWED_ORIGINS: "http://localhost", + // Isolate from host env — avoid namedVaults leaking in from the + // maintainer's Windows shell. + ...Object.fromEntries( + Object.keys(process.env) + .filter((k) => k.startsWith("OBSIDIAN_VAULT_") && k !== "OBSIDIAN_VAULT_PATH") + .map((k) => [k, undefined]), + ), + } as NodeJS.ProcessEnv); + const context = createDomainContext(env); + if (sessionPath) { + const record: VaultRecord = { + id: "session", + name: "session", + path: sessionPath, + isDefault: false, + isActive: true, + source: "env-named", + exists: true, + }; + context.session.activeVault = record; + } + return context; +} + +// The precedence contract is: arg > session > env > error. +// These tests cover all 8 combinations of (arg, session, env) set/unset. +// This locks in the "no session → identical to v0.2.5" invariant. + +describe("requireVaultPath precedence (arg > session > env > throw)", () => { + it("1. arg + session + env → arg wins", () => { + const c = ctx("/env", "/session"); + expect(requireVaultPath(c, "/arg")).toBe("/arg"); + }); + + it("2. arg + session, no env → arg wins", () => { + const c = ctx(undefined, "/session"); + expect(requireVaultPath(c, "/arg")).toBe("/arg"); + }); + + it("3. arg + env, no session → arg wins", () => { + const c = ctx("/env", null); + expect(requireVaultPath(c, "/arg")).toBe("/arg"); + }); + + it("4. arg only → arg wins", () => { + const c = ctx(undefined, null); + expect(requireVaultPath(c, "/arg")).toBe("/arg"); + }); + + it("5. session + env, no arg → session wins", () => { + const c = ctx("/env", "/session"); + expect(requireVaultPath(c)).toBe("/session"); + }); + + it("6. session only → session wins", () => { + const c = ctx(undefined, "/session"); + expect(requireVaultPath(c)).toBe("/session"); + }); + + it("7. env only → env wins (v0.2.5 baseline behaviour)", () => { + const c = ctx("/env", null); + expect(requireVaultPath(c)).toBe("/env"); + }); + + it("8. nothing set → invalid_argument", () => { + const c = ctx(undefined, null); + expect(() => requireVaultPath(c)).toThrow(); + try { + requireVaultPath(c); + } catch (err) { + expect(err).toMatchObject({ code: "invalid_argument" }); + } + }); +}); + +describe("requireVaultPath — context without session (pre-v0.3.0 shape)", () => { + it("still works when session.activeVault is null (identity with v0.2.5)", () => { + const c = ctx("/env", null); + // Equivalent to how every existing call in the domain layer looks. + expect(requireVaultPath(c, undefined)).toBe("/env"); + expect(requireVaultPath(c, "/override")).toBe("/override"); + }); +}); diff --git a/tests/vault-tools.test.ts b/tests/vault-tools.test.ts new file mode 100644 index 0000000..d35ff37 --- /dev/null +++ b/tests/vault-tools.test.ts @@ -0,0 +1,170 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getEnv } from "../src/config/env.js"; +import { createDomainContext, requireVaultPath } from "../src/domain/context.js"; +import { vaultTools } from "../src/server/tools/vaults.js"; + +// Helper: grab a tool handler by name so we can exercise it directly +// (same surface the MCP registerTool wraps at runtime). +function tool(name: string) { + const t = vaultTools.find((tool) => tool.name === name); + if (!t) throw new Error(`tool ${name} not found`); + return t; +} + +async function mkTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), "kobsidian-vaults-")); +} + +// Build a domain context with the legacy host env stripped out (the +// maintainer's Windows shell may have OBSIDIAN_VAULT_PATH / named vars set) +// and our own test values layered in. +function ctxWith(envOverrides: Record) { + const clean: NodeJS.ProcessEnv = { ...process.env }; + for (const key of Object.keys(clean)) { + if (key.startsWith("OBSIDIAN_VAULT_") && key !== "OBSIDIAN_VAULT_PATH") { + delete clean[key]; + } + } + const env = getEnv({ + ...clean, + KOBSIDIAN_ALLOWED_ORIGINS: "http://localhost", + // Disable obsidian.json discovery in tests so we don't pick up the + // maintainer's real vaults and pollute expectations. + KOBSIDIAN_VAULT_DISCOVERY: "off", + ...envOverrides, + } as NodeJS.ProcessEnv); + return createDomainContext(env); +} + +describe("vault.list / vault.current / vault.select / vault.reset", () => { + let vaultA: string; + let vaultB: string; + let tempRoot: string; + + beforeAll(async () => { + tempRoot = await mkTempDir(); + vaultA = path.join(tempRoot, "vault-a"); + vaultB = path.join(tempRoot, "vault-b"); + await fs.mkdir(vaultA, { recursive: true }); + await fs.mkdir(vaultB, { recursive: true }); + }); + + afterAll(async () => { + await fs.rm(tempRoot, { recursive: true, force: true }); + }); + + it("vault.list returns default + named env vars when discovery is off", async () => { + const ctx = ctxWith({ + OBSIDIAN_VAULT_PATH: vaultA, + OBSIDIAN_VAULT_SCRATCH: vaultB, + }); + const result = (await tool("vault.list").handler(ctx, {})) as { + total: number; + items: Array<{ id: string; name: string; path: string; isDefault: boolean; source: string }>; + obsidianConfigPath: string | null; + }; + expect(result.total).toBe(2); + expect(result.obsidianConfigPath).toBeNull(); + const ids = result.items.map((r) => r.id).sort(); + expect(ids).toEqual(["default", "env:scratch"]); + const defaultItem = result.items.find((r) => r.id === "default"); + expect(defaultItem?.isDefault).toBe(true); + expect(defaultItem?.path).toBe(vaultA); + }); + + it("vault.current reports env-default when nothing is selected", async () => { + const ctx = ctxWith({ OBSIDIAN_VAULT_PATH: vaultA }); + const result = (await tool("vault.current").handler(ctx, {})) as { + reason: string; + active: { path: string } | null; + envDefault: { path: string } | null; + }; + expect(result.reason).toBe("env-default"); + expect(result.active?.path).toBe(vaultA); + expect(result.envDefault?.path).toBe(vaultA); + }); + + it("vault.select by name flips the active vault; requireVaultPath picks it up", async () => { + const ctx = ctxWith({ + OBSIDIAN_VAULT_PATH: vaultA, + OBSIDIAN_VAULT_SCRATCH: vaultB, + }); + + // Baseline: filesystem tools resolve to vaultA. + expect(requireVaultPath(ctx)).toBe(vaultA); + + const select = (await tool("vault.select").handler(ctx, { name: "scratch" })) as { + changed: boolean; + active: { path: string; isActive: boolean }; + previous: unknown; + }; + expect(select.changed).toBe(true); + expect(select.active.path).toBe(vaultB); + expect(select.active.isActive).toBe(true); + + // After select: filesystem tools resolve to vaultB. + expect(requireVaultPath(ctx)).toBe(vaultB); + + // Explicit per-call arg STILL wins over the session selection. + expect(requireVaultPath(ctx, vaultA)).toBe(vaultA); + }); + + it("vault.select by ad-hoc path accepts unregistered directories", async () => { + const ctx = ctxWith({ OBSIDIAN_VAULT_PATH: vaultA }); + const freshVault = path.join(tempRoot, "fresh"); + await fs.mkdir(freshVault, { recursive: true }); + const select = (await tool("vault.select").handler(ctx, { path: freshVault })) as { + active: { path: string; id: string }; + }; + expect(select.active.path).toBe(path.resolve(freshVault)); + expect(select.active.id.startsWith("path:")).toBe(true); + expect(requireVaultPath(ctx)).toBe(path.resolve(freshVault)); + }); + + it("vault.reset clears the selection and falls back to env default", async () => { + const ctx = ctxWith({ + OBSIDIAN_VAULT_PATH: vaultA, + OBSIDIAN_VAULT_SCRATCH: vaultB, + }); + await tool("vault.select").handler(ctx, { name: "scratch" }); + expect(requireVaultPath(ctx)).toBe(vaultB); + + const reset = (await tool("vault.reset").handler(ctx, {})) as { + changed: boolean; + previous: { path: string } | null; + }; + expect(reset.changed).toBe(true); + expect(reset.previous?.path).toBe(vaultB); + expect(requireVaultPath(ctx)).toBe(vaultA); + + // Second reset is a no-op. + const reset2 = (await tool("vault.reset").handler(ctx, {})) as { changed: boolean }; + expect(reset2.changed).toBe(false); + }); + + it("vault.select rejects a denied vault (allow/deny gating)", async () => { + const ctx = ctxWith({ + OBSIDIAN_VAULT_PATH: vaultA, + OBSIDIAN_VAULT_SCRATCH: vaultB, + KOBSIDIAN_VAULT_DENY: "scratch", + }); + await expect(tool("vault.select").handler(ctx, { name: "scratch" })).rejects.toMatchObject({ + code: "unauthorized", + }); + }); + + it("vault.select rejects a non-existent path", async () => { + const ctx = ctxWith({ OBSIDIAN_VAULT_PATH: vaultA }); + await expect( + tool("vault.select").handler(ctx, { path: path.join(tempRoot, "nope") }), + ).rejects.toMatchObject({ code: "not_found" }); + }); + + it("vault.select Zod rejects providing two of {id, name, path}", async () => { + const ctx = ctxWith({ OBSIDIAN_VAULT_PATH: vaultA }); + await expect(tool("vault.select").handler(ctx, { name: "a", path: vaultA })).rejects.toThrow(); + }); +}); From d2209ce94f2d49b7d88a2a14ab9736b8b6b06915 Mon Sep 17 00:00:00 2001 From: Behzat Can Acele <61169260+bezata@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:19:21 +0300 Subject: [PATCH 2/2] docs(workspaces): add README callout + docs/WORKSPACES.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Highlight v0.3.0's multi-vault support as the differentiator: no other Obsidian MCP lets an LLM list and switch between vaults in-session. - New docs/WORKSPACES.md — focused feature page: 60-second tour, all three discovery sources (with the EXPERIMENTAL label on obsidian.json parsing), precedence chain, operator gating, HTTP caveat, cross-links to ENVIRONMENT.md / MIGRATION.md / CHANGELOG.md / tools.md. - README top-of-page callout linking to docs/WORKSPACES.md so the feature is visible to anyone scanning the hero block. - Link WORKSPACES.md from docs/README.md (the docs landing page) with its own section above Architecture. - Fixed stale MCP_tools=90 badge in the hero — now reflects the actual 66 tools. No code changes — typecheck + lint + test + inventory all still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 10 ++- docs/README.md | 6 ++ docs/WORKSPACES.md | 152 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 docs/WORKSPACES.md diff --git a/README.md b/README.md index cdfb318..e83ec5d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ You curate the sources; the LLM does the bookkeeping. [![MCP](https://img.shields.io/badge/MCP-2025--11--25-1e88e5?logo=anthropic&logoColor=white)](https://modelcontextprotocol.io) [![Bun](https://img.shields.io/badge/runtime-Bun_1.3+-f472b6?logo=bun&logoColor=white)](https://bun.sh) [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) -[![Tools](https://img.shields.io/badge/MCP_tools-90-1e88e5)](docs/tools.md) +[![Tools](https://img.shields.io/badge/MCP_tools-66-1e88e5)](docs/tools.md) [![Resources](https://img.shields.io/badge/MCP_resources-4-1e88e5)](docs/tools.md#resources) [![Prompts](https://img.shields.io/badge/MCP_prompts-3-1e88e5)](docs/tools.md#prompts) [![Smithery](https://img.shields.io/badge/Smithery-listed-8b5cf6)](https://smithery.ai) @@ -40,6 +40,14 @@ You curate the sources; the LLM does the bookkeeping. --- +> **🧰 The only Obsidian MCP with workspaces.** `vault.list` / `vault.select` +> let an LLM discover and switch between your Obsidian vaults in-session — +> no restart, no config edit, no per-tool path threading. Backwards +> compatible with `OBSIDIAN_VAULT_PATH`. Added in v0.3.0. +> See **[docs/WORKSPACES.md](docs/WORKSPACES.md)**. + +--- + ## Why kObsidian - **Filesystem-first.** Operates on your vault directly. Obsidian doesn't need to be running for 55+ of the 66 tools. diff --git a/docs/README.md b/docs/README.md index c94ba58..788a249 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,12 @@ Deeper reading, grouped by what you want to do. +## Workspaces (multi-vault) + +- **[WORKSPACES.md](WORKSPACES.md)** — the only Obsidian MCP with in-session + vault switching (`vault.list` / `vault.select` / …). Discovery sources, + precedence chain, security gating, HTTP caveats. + ## Architecture - **[architecture.md](architecture.md)** — the request → tools → domain → vault diff --git a/docs/WORKSPACES.md b/docs/WORKSPACES.md new file mode 100644 index 0000000..aebd2ef --- /dev/null +++ b/docs/WORKSPACES.md @@ -0,0 +1,152 @@ +# Workspaces — multi-vault for Obsidian MCP + +> **The only Obsidian MCP server that lets an LLM discover and switch +> between your Obsidian vaults in-session.** No restart, no config edit, +> no per-tool `vaultPath` threading. Added in v0.3.0. + +--- + +## Why this matters + +Other Obsidian MCP servers are hard-wired to a single vault path at +startup. If you have a "Work" vault and a "Personal" vault, you either +run two server processes, restart one with a different env var, or +manually thread absolute paths through every tool call. + +kObsidian's **`vault.*` namespace** closes that gap: + +- `vault.list` — enumerate every vault kObsidian can see +- `vault.current` — show which vault the next filesystem tool will hit +- `vault.select` — switch the active vault for the rest of the session +- `vault.reset` — go back to the default + +Every existing filesystem tool (`notes.*`, `tags.*`, `dataview.*`, +`blocks.*`, `canvas.*`, `kanban.*`, `marp.*`, `templates.*`, `tasks.*`, +`links.*`, `wiki.*`, `stats.vault`) transparently respects the +selection. No migration. No breaking changes. + +--- + +## 60-second tour (from the LLM's side) + +``` +You: "Switch to my Work vault and show me this week's journal entries." + +LLM: vault.list {} + → { items: [ + { id: "default", name: "Personal", isActive: true, source: "env-default" }, + { id: "env:work", name: "work", isActive: false, source: "env-named" }, + { id: "58f115bd2c2febd2", name: "Archive", isActive: false, source: "obsidian-app", + lastOpened: "2026-04-21T18:09:13.677Z" }, + ] } + + vault.select { name: "work" } + → { changed: true, active: { name: "work", path: "/Users/me/Work" } } + + notes.list { since: "2026-04-19" } + → (now hits /Users/me/Work, not /Users/me/Personal) +``` + +From here on every filesystem tool resolves to the Work vault until you +`vault.reset` or restart the server. `workspace.*` and `commands.*` +tools still bridge to whichever vault the live Obsidian process has +open (they're tied to the Local REST API, not the filesystem +selection) — `vault.current` surfaces that distinction in its +`obsidianLiveInstance` field. + +--- + +## How kObsidian discovers vaults + +Three sources, merged + deduplicated by canonical path: + +| Source | Status | How to enable | +|---|---|---| +| `OBSIDIAN_VAULT_PATH` | **Documented, stable** — the default, unchanged since v0.1. | Set the env var. | +| `OBSIDIAN_VAULT_=path` | **Documented, stable** — new in v0.3.0. Any number of named extras. Suffix lowercases to the vault name. | `OBSIDIAN_VAULT_WORK=/Users/me/Work`, `OBSIDIAN_VAULT_PERSONAL=/Users/me/Personal`, etc. | +| `obsidian.json` | **EXPERIMENTAL** — parses Obsidian's own vault registry. Zero config: if you've opened a vault in Obsidian, kObsidian can see it. The file format is internal to Obsidian and undocumented; stable in practice since v1.0 but could change without notice. | `KOBSIDIAN_VAULT_DISCOVERY=on` (default). Set `off` to opt out entirely. | + +**If you want zero dependency on Obsidian internals** — just use the +env-var sources and set `KOBSIDIAN_VAULT_DISCOVERY=off`. Your +configuration is then fully documented and predictable. + +### `obsidian.json` paths per OS + +| OS | Path | +|---|---| +| macOS | `~/Library/Application Support/obsidian/obsidian.json` | +| Windows | `%APPDATA%\obsidian\obsidian.json` | +| Linux (native) | `$XDG_CONFIG_HOME/obsidian/obsidian.json` → `~/.config/obsidian/obsidian.json` | +| Linux (Flatpak) | `~/.var/app/md.obsidian.Obsidian/config/obsidian/obsidian.json` | +| Linux (Snap) | `~/snap/obsidian/current/.config/obsidian/obsidian.json` | +| Portable / WSL | Set `KOBSIDIAN_OBSIDIAN_CONFIG=/absolute/path/obsidian.json` | + +Parse failures are surfaced via `vault.list`'s `obsidianConfigError` +response field. They never crash the server. + +--- + +## Precedence chain + +``` +requireVaultPath(context, args.vaultPath): + args.vaultPath // per-call override — highest + ?? context.session.activeVault?.path // set by vault.select (new in v0.3.0) + ?? context.env.OBSIDIAN_VAULT_PATH // startup default — unchanged + ?? throw AppError("invalid_argument") +``` + +The v0.3.0 change is strictly additive. If you never call +`vault.select`, the middle slot is empty and the chain collapses to +v0.2.5's `arg > env > error` exactly. + +**Explicit per-call `vaultPath` always wins.** If you pass `vaultPath` +to a tool, it beats the session selection every time. This lets you +script batch operations that touch multiple vaults without having to +`select` / `reset` around each call. + +--- + +## Security / operator gating + +When you give an LLM multi-vault powers, you might want to bound what +it can see or switch to: + +| Var | Effect | +|---|---| +| `KOBSIDIAN_VAULT_ALLOW=Work,Personal` | Allowlist — names or absolute paths, comma-separated. Non-matching vaults are filtered out of `vault.list` entirely and rejected by `vault.select` with `unauthorized`. | +| `KOBSIDIAN_VAULT_DENY=secrets,/Users/me/Archive/Medical` | Denylist — applied after the allowlist. Useful for hiding sensitive vaults even if they're in Obsidian's registry. | +| `KOBSIDIAN_VAULT_DISCOVERY=off` | Hard-disable `obsidian.json` parsing. Only env-var-configured vaults will appear. | + +**`OBSIDIAN_VAULT_PATH` is never filtered by allow/deny.** The +operator's blessed default always stays reachable — otherwise a typo +in a deny rule could lock your own server out of the vault it's +supposed to run against. + +`vault.select` distinguishes two error classes for clean UX: + +- **`not_found`** — the vault doesn't exist (bad `id`/`name`/`path`, or missing from the registry). +- **`unauthorized`** — the vault exists but is blocked by operator gating. + +--- + +## HTTP transport caveat + +The current HTTP transport shares one `DomainContext` across concurrent +clients, so a session-active vault selected by one client is visible to +all others. In practice every kObsidian HTTP deployment today is +single-client (the Local REST API bridge binds to `127.0.0.1`), so this +is acceptable. + +If you run a multi-client HTTP setup and want strict isolation, pass +`vaultPath` on every tool call and skip `vault.select`. Per- +`Mcp-Session-Id` scoping is on the roadmap for a future v0.3.x. + +--- + +## Related docs + +- [`../CHANGELOG.md`](../CHANGELOG.md) — v0.3.0 release notes +- [`ENVIRONMENT.md`](ENVIRONMENT.md) — full env-var reference +- [`MIGRATION.md`](MIGRATION.md) — upgrading from v0.2.5 +- [`tools.md`](tools.md) — complete tool surface (66 tools, 16 namespaces)