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
Markdown
---
summary: Full rewrite on /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.0 — 2026-04-28
Major version: complete rewrite of `obsidian-mcp-server` on top of [`/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: `/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`), `/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.