UNPKG

obsidian-mcp-server

Version:

MCP server for Obsidian vaults — read, write, search, and surgically edit notes, tags, and frontmatter via the Local REST API plugin. STDIO or Streamable HTTP.

103 lines (81 loc) 19.8 kB
--- summary: Full rewrite on @cyanheads/mcp-ts-core. 14 tools and 3 resources expose the Obsidian Local REST API as a typed, declarative MCP surface — section-aware editing, three-mode search, and tag reconciliation. breaking: true --- # 3.0.02026-04-28 Major version: complete rewrite of `obsidian-mcp-server` on top of [`@cyanheads/mcp-ts-core`](https://www.npmjs.com/package/@cyanheads/mcp-ts-core). The server exposes the Obsidian Local REST API plugin as a typed, declarative MCP surface — 14 tools and 3 resources, with section-aware editing, three-mode search, and tag reconciliation across inline and frontmatter representations. ## Migrating from 2.x The 2.x tool surface is gone — every tool name and input/output schema changed. Consumers must update their integrations. ### Tool name map | 2.x | 3.0.0 | |:----|:------| | `obsidian_read_note` | `obsidian_get_note` (`format`: `content` / `full` / `document-map` / `section` — replaces the v2 `markdown` vs `json` toggle and adds section-projection reads) | | `obsidian_update_note` (single tool, `append` / `prepend` / `overwrite` modes) | Split: `overwrite` `obsidian_write_note` (full-file PUT, plus new in-place section replace); `append` `obsidian_append_to_note`. **Whole-file `prepend` is gone** — the closest paths are prepending before a known heading via `obsidian_patch_note` (new, section-aware) or read-modify-write through `obsidian_get_note` + `obsidian_write_note`. The `wholeFileMode` argument is gone with the split. | | `obsidian_search_replace` | `obsidian_replace_in_note` — preserves `caseSensitive`, `wholeWord`, `flexibleWhitespace`, and `replaceAll` from v2; now also honors `$1` / `$&` capture-group references in regex replacements. | | `obsidian_global_search` | `obsidian_search_notes` — three modes: text, Dataview DQL, JSONLogic. **Text mode is plain substring** — v2's regex toggle moved to JSONLogic's `regexp` operator. **Modification-date filtering moved to Dataview/JSONLogic** (`file.mtime` / `stat.mtime`); v2's text-mode date filter is gone. Pagination replaced by a 100-hit cap with an `excluded` indicator and a narrowing hint. | | `obsidian_list_notes` | Same name. **Recursive walk** is back, bounded — default depth 2 (target dir plus its immediate children, a structural overview), max 20, capped at 1000 entries per call. Same extension and name-regex filters apply across the tree (regex-filtered directories are skipped without recursing into them). Returns flat `entries[]` (each `{path, type, truncated?}`) plus a box-drawing tree in the rendered output. Per-directory `truncated: true` flags where the depth limit cut off recursion; top-level `excluded.reason: 'entry_cap'` fires when the global entry cap stops the walk. v2's flat `files[]` / `directories[]` arrays are replaced by the unified `entries[]`; v2's infinite-recursion default is gone. | | `obsidian_manage_frontmatter` | Same name. Atomic `get` / `set` / `delete` on a single key. | | `obsidian_manage_tags` | Same name. Now reconciles inline `#tag` and frontmatter `tags:` together; skips fenced code blocks. | | `obsidian_delete_note` | Same name. Uses `ctx.elicit` for human-in-the-loop confirmation when the client supports it. v2's silent case-insensitive path fallback was dropped — delete now requires an exact path match so a typo can't quietly delete a different file on case-sensitive filesystems. | **New in 3.0.0:** `obsidian_list_tags`, `obsidian_list_commands`, `obsidian_open_in_ui`, `obsidian_patch_note` (section-aware edits — append / prepend / replace against headings, block refs, frontmatter fields), and `obsidian_execute_command` (opt-in via `OBSIDIAN_ENABLE_COMMANDS=true`). v2 had no resources; 3.0.0 adds three: `obsidian://vault/{+path}` (note as parsed `NoteJson`), `obsidian://tags`, and `obsidian://status`. ### Removed - **`VaultCacheService`** and its env vars (`OBSIDIAN_ENABLE_CACHE`, `OBSIDIAN_CACHE_REFRESH_INTERVAL_MIN`). The server is stateless now — search and list hit the Local REST API on every call. If you relied on the cache as a fallback when upstream was unreachable, those failures now surface as service errors instead. For large vaults, narrow with the text-mode `pathPrefix` filter or Dataview/JSONLogic predicates against `file.path` / `path`. ### Default and config changes - `OBSIDIAN_VERIFY_SSL` default flipped from `true` to `false`. The plugin's HTTPS port (27124) uses a self-signed cert and the HTTP port (27123) doesn't need verification — v2's `true` default required explicit overrides for almost every realistic config. - `OBSIDIAN_BASE_URL` default unchanged (`http://127.0.0.1:27123`) but no longer marked required — the Zod default supplies it. - Runtime requirements: `engines.node` bumped from `>=18` to `>=22`; `engines.bun` 1.3.11 is the install-time constraint because the dev workflow (build, test, devcheck) assumes Bun. ### Architectural notes - The server is no longer a fork of `cyanheads/mcp-ts-template`; it depends on [`@cyanheads/mcp-ts-core`](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) as a package. Tools and resources are declarative definitions registered via `tool('name', { ... })` and `resource('uri', { ... })` builders. - Per-request cancellation: `ctx.signal` propagates through to the upstream HTTP client, so client cancellations and per-request timeouts cut off in-flight Local REST API calls. - HTTP-mode auth (`MCP_AUTH_MODE=jwt|oauth`) and tenant-aware logging via `ctx.tenantId` are framework features, retained from v2's posture. ## Added - **Read tools:** `obsidian_get_note` (content / full / document-map / section projections), `obsidian_list_notes` (recursive walk with bounded `depth` plus extension and name-regex filters; returns flat `entries[]` and a box-drawing tree view), `obsidian_list_tags`, `obsidian_list_commands`, `obsidian_search_notes` (text / Dataview DQL / JSONLogic). - **Write tools:** `obsidian_write_note` (full-file PUT or in-place section replace), `obsidian_append_to_note`, `obsidian_patch_note` (append / prepend / replace against headings, block refs, frontmatter fields), `obsidian_replace_in_note` (string and regex search-replace). - **Manage tools:** `obsidian_manage_frontmatter` (atomic get / set / delete on a single key), `obsidian_manage_tags` (reconciles frontmatter `tags:` and inline `#tag`, skipping fenced code blocks). - **Destructive tools:** `obsidian_delete_note` (with `ctx.elicit` confirmation when supported), `obsidian_open_in_ui`. - **Opt-in tool:** `obsidian_execute_command` — registered only when `OBSIDIAN_ENABLE_COMMANDS=true`. - **Resources:** `obsidian://vault/{+path}` (note as parsed NoteJson), `obsidian://tags`, `obsidian://status`. - **Service layer:** Typed Local REST API client with `ctx.signal` propagation, frontmatter parse/serialize/edit helpers, section extraction across headings/blocks/frontmatter. - **Configuration:** Five `OBSIDIAN_*` env vars wired through `parseEnvConfig` with Zod validation. - **Search guardrails:** 100-hit cap with an `excluded` indicator that surfaces overflow count and a narrowing hint. Text-mode results additionally clip per file at `maxMatchesPerHit` (default 10) so a single match-heavy note can't blow the response budget — clipped hits carry `truncated: true` and `totalMatches`. Caller can override the per-hit cap. - **Deployment:** Dockerfile (HTTP transport, stateless session, OCI metadata) and `server.json` manifest for MCP registry listings. - **Path resolution UX:** Read and open tools (`obsidian_get_note`, `obsidian_open_in_ui`) share a `withCaseFallback()` helper. On a 404 against a `path:` target, it lists the parent directory and either (a) silently retries against the canonical filename when exactly one case-insensitive match exists (fixes `Readme.md` vs `README.md` on case-sensitive filesystems), (b) throws `Conflict` with the candidates when multiple case matches exist, or (c) re-throws `NotFound` with `data.suggestions[]` and a `Did you mean: "X", "Y"?` hint for extension-stripped near-matches. Resolved canonical paths flow back through `result.path`. `obsidian_delete_note` opts out — silent path normalization on a destructive op is too dangerous when not every client supports `ctx.elicit`. - **`obsidian_replace_in_note` `wholeWord` and `flexibleWhitespace` options** for parity with v2's `obsidian_search_replace`. `wholeWord` works in both literal and regex modes (wraps the pattern in `\b\b`). `flexibleWhitespace` is literal-mode only — substitutes any run of whitespace in the search pattern with `\s+` so `"foo bar"` matches `"foo\tbar"` and `"foo bar"` alike. Literal-with-transformations uses the callback overload of `String.replace` so `$1` / `$&` in `replace` stay literal (only `useRegex: true` honors capture-group references). - **Typed error contracts on every tool.** All 10 tools declare an `errors: [{ reason, code, when, retryable? }]` array advertising the failure surface in `tools/list` (under `_meta['mcp-ts-core/errors']`); throws route through `ctx.fail(reason, …)` so `error.data.reason` is auto-populated and tamper-proof, and clients can switch on a stable identifier instead of parsing message text. Declared reasons: `note_missing`, `no_active_file`, `periodic_not_found`, `command_unknown`, `section_target_missing`, `section_required`, `value_required`, `tags_required`, `regex_invalid`, `query_required`, `logic_required`, `path_prefix_invalid_mode`, and `cancelled` (delete declined via elicitation). ## Changed - `obsidian_write_note` now sets `Apply-If-Content-Preexists: true` on section-targeted PATCH and strips a leading duplicate heading line so a caller's `## Section` does not get embedded inside the section body. - `obsidian://status.authenticated` is now a separate authenticated probe against `/vault/` instead of the unauthenticated `/` field — the resource still serves reachability info when the API key is wrong. - Service errors report the vault-relative path (e.g. `Not found: Notes/foo.md`) instead of the internal HTTP path (`/vault/Notes/foo.md`). Periodic-note paths render as `daily note for 2026-04-26`, so 404s and log lines name the resource the caller asked for instead of the route. - 400 responses that look like a missing PATCH section target are translated into actionable guidance pointing at `obsidian_get_note` `format: "document-map"`, with a reminder that nested headings need `Parent::Child` syntax. - `obsidian_get_note` `format()` renders ISO 8601 timestamps for `stat.ctime` / `stat.mtime` instead of raw epoch ms. - `obsidian_search_notes` empty-result rendering includes a narrowing hint; the JSONLogic mode description now enumerates the available `var` paths (`path`, `content`, `frontmatter.<key>`, `tags`, `stat.{ctime,mtime,size}`). - `obsidian_list_notes` echoes any active filters as `appliedFilters` (extension and/or `nameRegex`) in both `structuredContent` and the rendered text twin, so callers can audit which filters narrowed the listing. - `obsidian_open_in_ui` no longer carries `destructiveHint` — the `failIfMissing: true` default keeps the call read-like; opt-in create-on-open lives behind that flag. `obsidian_append_to_note` is now correctly flagged `destructiveHint`. - `obsidian_write_note` and `obsidian_append_to_note` outputs drop the `updatedContent` echo. The Local REST API doesn't reliably return a body on the whole-file path, so the field was a coin flip; section-targeted edits already round-trip the new content through a separate read. - Tool descriptions across `obsidian_*` rewritten to replace internal jargon (`OBSIDIAN_ENABLE_COMMANDS`, "upstream", "PATCH-with-append", "404", "NoteJson", "Echoed") with user-facing concepts and concrete next steps. Notable clarifications: heading projections include the full subtree (`obsidian_get_note`); `extension` filters files only, directories pass through (`obsidian_list_notes`); inline-location adds append at end of file and `list` ignores the `tags` field (`obsidian_manage_tags`); block-reference appends are concatenated without an upstream-inserted separator (`obsidian_append_to_note`); `contentType: "json"` requires JSON-literal encoding to avoid opaque upstream 400s (`ContentTypeSchema`). Cross-tool routing hints removed from `obsidian_open_in_ui` and `obsidian_manage_frontmatter` headers. - `obsidian_search_notes` annotations include `idempotentHint: true` alongside `readOnlyHint: true` — same query same results, both hints apply. - `obsidian_manage_frontmatter` `delete` projects the post-state frontmatter locally by stripping the deleted key from the prior read instead of refetching. One fewer round-trip per delete; the upstream PUT is the source of truth either way. - `obsidian_manage_tags` short-circuits on no-op reconciles. When `add`/`remove` resolves to zero applied changes, the handler skips both the upstream PUT and the post-write GET refetch and returns directly from the pre-read note. Re-adding tags a note already carries now costs one round-trip instead of two. - `obsidian_open_in_ui` skips the existence probe when `failIfMissing: false`. The probe only existed to populate `createdIfMissing`, which is unambiguously `true` under the create-on-open contract — the open call is now a single round-trip with no upfront `GET /vault/<path>` or parent listing. - `obsidian_search_notes` rejects `pathPrefix` outside `text` mode with an actionable error. The filter was silently ignored in `dataview` and `jsonlogic` (those modes filter by `path` / `file.path` predicates directly), which made result counts surprising. - `npm start` script added (`node dist/index.js`) for distribution-time launches that don't go through the `MCP_TRANSPORT_TYPE`-prefixed `start:stdio` / `start:http` variants. - Dependency bumps: `@cyanheads/mcp-ts-core` 0.7.5 → 0.8.1 (picks up typed error contracts, the `errors[]` conformance lint, and `httpErrorFromResponse`), `@modelcontextprotocol/sdk` ^1.13.0 ^1.29.0 (transitive via `mcp-ts-core`), `@biomejs/biome` 2.4.7 2.4.13, `typescript` 5.9.3 6.0.3 (major), `vitest` 4.1.0 4.1.5. - README polish: the `obsidian_open_in_ui` row documents both `failIfMissing` and `newLeaf` toggles; the project-structure table no longer references the removed `docs/design.md` (superseded by the shipped implementation). - Internal cleanup: `ObsidianService.resolvePath()` collapses the repeated `target.type === 'path' ? : getNoteJson()` ternary across handlers; `splice()` in `frontmatter-ops` captures YAML directly from the regex match group; `obsidian_open_in_ui` consolidates two near-duplicate try blocks; document-map fetch and path resolution run in parallel for non-`path` targets. - Test infrastructure: `ObsidianService` constructor accepts an optional `fetchImpl` (`(url, init) => Promise<Response>`) instead of an undici `Dispatcher`. Bun's runtime treats `undici` as a builtin and silently ignores `vi.mock('undici', )`, so tests inject a stub fetch directly. The harness keeps the same `pool.intercept(matcher).reply(...)` surface; no test bodies changed. - Service-layer error classification carries `data.reason`. 404s are tagged by origin path: `/active/` `no_active_file`, `/periodic/` `periodic_not_found`, `/commands/` `command_unknown`, vault paths `note_missing`. 405 on a directory surfaces `path_is_directory`; 400 "could not be applied" PATCH responses surface `section_target_missing`. Service-thrown reasons flow through the auto-classifier unchanged, so clients see the same `error.data.reason` whether the throw originated in a service or a tool's `ctx.fail`. - Semantic post-shape validation switched from `invalidParams` (-32602) to `validationError` (-32007). Once Zod accepts the input, downstream domain checks (mode-mismatched `pathPrefix`, missing `query` / `logic` / `section` / `value` / `tags`, malformed regex, malformed periodic-note date, 405 directory PUT) are validation failures, not protocol-level wrong-shape — `invalidParams` is reserved for the framework's pre-Zod JSON-RPC decoder. Wire-visible: code `-32007` instead of `-32602`. - `obsidian_execute_command` 404s render `Unknown Obsidian command: <id>. Use \`obsidian_list_commands\` to discover valid command IDs.` instead of the bare upstream `Not found: `. Service `displayPath()` strips `/commands/` so log lines name the command id, not the route. - `obsidian_open_in_ui` keys off `err.data.reason === 'note_missing'` rather than `err.code === JsonRpcErrorCode.NotFound` when deciding whether to rewrite a `NotFound` from `withCaseFallback` into a `failIfMissing: true` rejection — satisfies `error-contract-prefer-fail`; conformance lint is now clean across every handler. - CLAUDE.md `Errors` section reframed around the typed contract as the recommended path; factories scoped to ad-hoc cases and service-layer code. Skills `add-tool`, `add-service`, `api-errors`, `field-test`, `maintenance`, `report-issue-framework`, and `security-pass` document declaring `errors[]`, routing throws through `ctx.fail`, carrying contract `reason` from services via factory `data: { reason }`, and field-testing each declared reason against `_meta.error.code` and `data.reason`. ## Fixed - `obsidian_replace_in_note` honors `$1` / `$2` / `$&` capture-group references in regex replacements. The regex path now uses the string overload of `String.replace` instead of the callback overload, which silently drops capture-group templates. - `obsidian_list_tags` and `obsidian://tags` return `{name, count}` matching the upstream `/tags/` shape (was `{tag, count}`). - `NoteTarget.date` aligned with the Zod-inferred shape (`string | undefined`) so handlers no longer need an `as NoteTarget` cast under `exactOptionalPropertyTypes`. - `OBSIDIAN_VERIFY_SSL=false` actually disables TLS verification under Bun. Bun ignores undici's per-dispatcher `connect.rejectUnauthorized`, so calls to the HTTPS port (`https://127.0.0.1:27124`, self-signed) failed with `DEPTH_ZERO_SELF_SIGNED_CERT` regardless of the env var. The service constructor sets `NODE_TLS_REJECT_UNAUTHORIZED=0` only when `verifySsl: false` **and** the runtime is Bun — Node honors the dispatcher option directly and is not mutated process-wide. - `extractSection` (heading and block) skips a leading YAML frontmatter block before scanning. Without this guard, a `# yaml comment` inside frontmatter false-matched as a Markdown heading, and block extraction's paragraph walk-back could pull the closing `---` fence into the result. - `obsidian_manage_frontmatter` `get` on an absent key returns `value: null` in `structuredContent` instead of omitting the field — closes a parity gap with `content[]`, which already rendered `_(absent)_`. - `ObsidianService.probeAuthenticated` re-throws when `ctx.signal` is aborted instead of catching the abort and reporting `false`. A cancelled or timed-out probe was masquerading as a 401 in `obsidian://status.authenticated`; cancellation now surfaces as a service error. ## Security - Upstream error bodies pass through a `safeUpstream()` helper before being attached to JSON-RPC `error.data` — it keeps only the human `message` field (or trims raw text to 200 chars) and drops plugin-internal fields like `errorCode` that would otherwise leak into client-visible payloads. - The TLS verify-disable fallback (`NODE_TLS_REJECT_UNAUTHORIZED=0`) is **scoped to Bun**. Outbound HTTPS only goes to the configured Obsidian Local REST API, so the blast radius is the upstream you already chose to trust by setting `verifySsl: false`. Node deployments use undici's per-dispatcher `rejectUnauthorized: false` and don't mutate process-wide TLS. The `OBSIDIAN_VERIFY_SSL` env var description calls this out.