Read, write, search, and surgically edit Obsidian notes, tags, and frontmatter via MCP.
Runs on your machine from its package; the steps are below.
At most: moves money, runs code or commands, deletes data, or sends messages for you.
Odel charges nothing per call. The publisher may charge for its own service.
Runs locally over stdio
This server isn't hosted — your MCP client launches it from a package registry. Use one of the commands below, or drop the config into your client (e.g. Claude Desktop).
Run
npx -y obsidian-mcp-server
MCP client config
{
"mcpServers": {
"obsidian": {
"command": "npx",
"args": [
"-y",
"obsidian-mcp-server"
]
}
}
}Run
npx -y obsidian-mcp-server
MCP client config
{
"mcpServers": {
"obsidian": {
"command": "npx",
"args": [
"-y",
"obsidian-mcp-server"
]
}
}
}Read, write, search, and surgically edit Obsidian vault notes, tags, and frontmatter via MCP. STDIO or Streamable HTTP.
Your Obsidian vault, through the Local REST API plugin. Read, search, and write notes, edit a single heading, block, or frontmatter field in place, and manage tags, with folder-scoped read/write permissions built in.
| Tool | Description |
|---|---|
obsidian_get_note | Read a note as raw content, full structured form, document map, or a single section |
obsidian_list_notes | List notes and folders under a vault path, recursively, with extension and name filters |
obsidian_list_tags | List vault tags with usage counts, most-used first |
obsidian_search_notes | Search by text, JSONLogic, or BM25-ranked Omnisearch when that plugin is reachable |
obsidian_write_note | Create a note, replace one section, or overwrite a whole file with overwrite: true |
obsidian_append_to_note | Append to a note (creating it if missing) or to one heading, block, or frontmatter field |
obsidian_patch_note | Append, prepend, or replace against one heading, block reference, or frontmatter field |
obsidian_replace_in_note | Literal or regex search-replace inside one note, body-only by default |
obsidian_manage_frontmatter | Get, set, or delete one frontmatter key |
obsidian_manage_tags | Add, remove, or list a note's tags in frontmatter, inline, or both |
obsidian_delete_note | Permanently delete a note, optionally after the user confirms (OBSIDIAN_DELETE_ELICITATION) |
obsidian_open_in_ui | Open a file in the Obsidian app, optionally in a new pane |
obsidian_list_commands | List command-palette commands (opt-in via OBSIDIAN_ENABLE_COMMANDS) |
obsidian_execute_command | Run a command-palette command by ID (opt-in via OBSIDIAN_ENABLE_COMMANDS) |
| Resource | Description |
|---|---|
obsidian://vault/{+path} | A note's content, frontmatter, tags, and file metadata |
obsidian://tags | Every vault tag with its usage count, as an uncapped snapshot |
obsidian://status | Plugin reachability, auth status, versions, and registered API extensions |
Note and tag data are also reachable through tools (obsidian_get_note, obsidian_list_tags); obsidian://status has no tool equivalent.
obsidian_get_note tooltarget is a vault path, the active file, or a periodic note (daily through yearly, optional date); format is content, full, document-map, or section, and full takes includeLinks: true for vault-internal outgoing linksresult.format discriminates the payload; a section read that matches several headings returns the first and lists every full path in candidatespath with no exact match but one case-insensitive match in its folder reads that file: result.path is the real name, requestedPath the one sent, and a notice names bothobsidian_list_notes toolpath (default vault root) to depth 1–20 (default 2), filtered by extension and nameRegex (≤256 chars); a folder that fails nameRegex is not walkedentries[] (file / directory), totals, and appliedFilters; the walk stops at 1,000 entries with excluded.reason: "entry_cap", and a folder the depth limit stopped carries truncated: trueOBSIDIAN_READ_PATHS set, a listing holds only readable entries and the folders leading to the scope; those folders are walked and accepted as pathobsidian_list_tags toolnameRegex (≤256 chars) and minCount narrow the set, then tags are ranked by count and capped at limit (default 200, max 10000); hierarchical parents count (work/tasks adds to work)truncated, shown, and capOBSIDIAN_READ_PATHS set, only tags from readable notes are listed, and count is the number of readable notes carrying the tag or a tag nested under itobsidian_search_notes toolmode: "text" requires every whitespace-split token of query as a case-insensitive substring of the filename or body (quotes are literal), shaped by contextLength (default 100), pathPrefix, and maxMatchesPerHit (default 10); mode: "jsonlogic" evaluates a logic tree over path, content, frontmatter.<key>, tags, and stat (plus links, backlinks, and unresolvedLinks on plugin versions that provide them), with glob / regexp taking [PATTERN, VALUE]result.mode discriminates the payload; every mode reports totalCount and pages via nextCursor, and a text hit clipped to maxMatchesPerHit carries truncated and totalMatchesmode: "omnisearch" (BM25 ranking, quoted phrases, -exclusion, path: / ext: filters) is offered only when the Omnisearch plugin answered at startup; its 50-hit upstream cap sets truncated: trueobsidian_write_note tooltarget and content, with optional section and contentType (markdown / json); a whole-file write to an existing note fails with file_exists unless overwrite: truecase_variant_exists and writes nothing; existingPath names that note when the parent folder's listing shows exactly one case match, and is omitted when several match or when a file Obsidian does not index (a dot-file) sits at the pathsection, replaces only that heading, block, or frontmatter field and keeps the heading line; output reports created, sectionTargeted, and the resolved sectionTargetobsidian_append_to_note toolsection, appends to the file or creates it (created: true); with section, appends to that heading, block, or frontmatter field of an existing note, and createTargetIfMissing: true creates the sectioncontent_preexists; block targets add no separator, so start content with a newline if you want onecase_variant_exists on a case-insensitive filesystem, with existingPath naming that note when the parent folder's listing shows exactly one case matchobsidian_patch_note tooloperation: "append" | "prepend" | "replace" against one section of an existing note; patchOptions takes createTargetIfMissing, applyIfContentPreexists, and trimTargetWhitespace (plugin v4.x only)section and operation; a repeat of content already at the target fails with content_preexists unless applyIfContentPreexists: trueobsidian_replace_in_note toolreplacements[] run in order, each over the previous one's output; each takes useRegex (≤1024 chars), caseSensitive (default true), wholeWord, flexibleWhitespace (literal mode only), and replaceAll (default true)totalReplacements and perReplacement[] with bodyCount / frontmatterCountscope: "body" (default) leaves frontmatter byte-identical; "frontmatter" and "both" re-parse the YAML afterward and write nothing if it breaks (frontmatter_invalid)obsidian_manage_frontmatter tooloperation: "get" | "set" | "delete" on one key; set requires a JSON-typed valueget returns exists and value (null when absent); set and delete return the full frontmatter after the change, and a delete against unparseable YAML fails with frontmatter_invalid without writingobsidian_manage_tags tooloperation: "add" | "remove" | "list" with tags; location: "frontmatter" (default, the tags: array), "inline" (body #tag; add appends at end of file), or "both"add / remove report applied, skipped, and the resulting tags; list returns frontmatter, inline, and all%% … %% comments are readobsidian_delete_note tooltarget (path, active file, or periodic note) and checks it against the write scope first; a folder path fails with path_is_directory. There is no API-level undo, only Obsidian's local trashnote_missing before anything is deleted, naming the near matches in suggestionsOBSIDIAN_WRITE_PATHS and OBSIDIAN_READ_ONLY bound what it can reachOBSIDIAN_DELETE_ELICITATION=true, the first call answers with a confirmation request naming the path and byte size, and the note is deleted only after the user accepts. Declining fails with cancelled, and a client without elicitation support cannot deleteSTORAGE_PROVIDER_TYPE, default in-memory, process-local). That works for stdio or a single HTTP instance; several instances behind one endpoint need a shared provider (filesystem, supabase, or cloudflare-d1, never cloudflare-kv)obsidian_open_in_ui toolpath, failIfMissing (default true), and newLeaf (open in a split pane); with failIfMissing: false a missing file is created, which needs write accesscreatedIfMissing reports which branch ranpath with one case-insensitive match opens that file instead of creating another, reporting the path sent as requestedPath with a noticeobsidian_list_commands toolnameRegex (≤256 chars) matched against each command's display namecommands[] of id and name, where id feeds obsidian_execute_commandOBSIDIAN_ENABLE_COMMANDS=true and OBSIDIAN_READ_ONLY is offobsidian_execute_command toolcommandId from obsidian_list_commands; returns executed: true, or fails with command_unknown for an unregistered IDobsidian_list_commandsobsidian://vault/{+path} resource{+path} captures everything after /vault/, slashes included; literal and percent-encoded paths resolve to the same notepath, content, frontmatter, tags, and stat, the same shape as obsidian_get_note with format: "full"; failures are path_forbidden, note_missing, or path_is_directoryobsidian://tags resourcecount, uncapped and unsorted, hierarchical parents includedOBSIDIAN_READ_PATHS set, only tags from readable notes, counted the same way as obsidian_list_tagsobsidian_list_tags gives the count-ranked, capped viewobsidian://status resourcestatus, service, authenticated, versions, manifest, and apiExtensions[]; still answers with a wrong API key, reporting authenticated: falseapiExtensions for local-rest-api-periodic-notes before using periodic targetsBuilt on @cyanheads/mcp-ts-core: stdio and Streamable HTTP transports, pluggable auth (none / jwt / oauth), swappable storage (in-memory, filesystem, Supabase, Cloudflare KV/R2/D1), structured logging with optional OpenTelemetry tracing.
Obsidian-specific:
Parent::Child path or a bare leaf name; writes reject an ambiguous one with ambiguous_section and its candidates, unless exactly one match is a top-level heading. On plugin v5.0 and later, content added to a heading is set off by a blank line (a list item continues an adjacent list), and a heading inside it must sit below the section's level (heading_outside_section)instructions on initialize report the active policyobsidian_get_note and obsidian_open_in_ui retry a case-mismatched path against the real filename, disclosed as requestedPath plus a notice, and add Did you mean suggestions to a miss; writes and deletes match the exact pathnameRegex at 256 chars, useRegex at 1024) and rejected with regex_unsafe when they nest quantifiersobsidian_search_notes in jsonlogic mode finds them with {"regexp": ["\\[\\[Target Note(\\\\?\\||#|\\]\\])", {"var": "content"}]}, which also matches the table-safe [[Target Note\|Alias]]Agent-friendly output:
reason, a JSON-RPC code, and a recovery.hint written for that casepreviousSizeInBytes / currentSizeInBytes, so a caller can spot an accidental clobber without a follow-up readcandidates, and tag operations report applied vs. skippedformat on obsidian_get_note, mode on obsidian_search_notes, operation on obsidian_manage_frontmatter and obsidian_manage_tagsAdd it to Claude Code:
claude mcp add obsidian-mcp-server -e OBSIDIAN_API_KEY=your-api-key -- npx -y obsidian-mcp-server
For other clients, add this to the MCP config file:
{
"mcpServers": {
"obsidian-mcp-server": {
"command": "npx",
"args": ["-y", "obsidian-mcp-server"],
"env": { "OBSIDIAN_API_KEY": "your-api-key" }
}
}
}
Generate the key in Obsidian under Settings → Community Plugins → Local REST API, with the plugin set up as Prerequisites describes. For Docker or a source build, see Running the server.
OBSIDIAN_API_KEY.http://127.0.0.1:27123, so enable "Non-encrypted (HTTP) Server" in the plugin settings, or set OBSIDIAN_BASE_URL=https://127.0.0.1:27124 for the always-on HTTPS port (its self-signed cert is accepted while OBSIDIAN_VERIFY_SSL=false, the default).periodic_unsupported.contentType: "json") still use: rows under a heading, and rows through a block ID on its own line below the table. On v6.0, target the table by an ID on its last row.OBSIDIAN_DELETE_ELICITATION=true, an MCP client that supports elicitation and renders its form, to use obsidian_delete_note. Every other tool, and the default delete, works without it.| Variable | Description | Default |
|---|---|---|
OBSIDIAN_API_KEY | Required. Bearer token for the Local REST API plugin. | — |
OBSIDIAN_BASE_URL | Local REST API base URL; https://127.0.0.1:27124 is the always-on HTTPS port. A trailing slash is stripped. When nothing answers, calls fail with obsidian_unreachable. | http://127.0.0.1:27123 |
OBSIDIAN_VERIFY_SSL | Verify the plugin's TLS certificate. Off by default for its self-signed cert; the relaxation applies only to an https: OBSIDIAN_BASE_URL. With true, an untrusted cert fails calls with certificate_rejected. | false |
OBSIDIAN_REQUEST_TIMEOUT_MS | Per-request timeout in milliseconds. | 30000 |
OBSIDIAN_ENABLE_COMMANDS | Enable obsidian_list_commands and obsidian_execute_command. Commands are opaque and can be destructive. | false |
OBSIDIAN_READ_PATHS | Comma-separated folder allowlist for reads. See Path policy. | unset (full vault) |
OBSIDIAN_WRITE_PATHS | Comma-separated folder allowlist for writes. See Path policy. | unset (the read scope, or the full vault without one) |
OBSIDIAN_READ_ONLY | Deny every write and disable the command-palette pair. | false |
OBSIDIAN_DELETE_ELICITATION | Ask the user to confirm each obsidian_delete_note call through an elicitation form before deleting. Needs a client that renders elicitation, and a stateful session over HTTP (not required when OBSIDIAN_READ_ONLY=true disables the tool). Off, the delete runs on the first call. | false |
OBSIDIAN_OMNISEARCH_URL | Omnisearch HTTP server URL. Unset derives from the OBSIDIAN_BASE_URL host on port 51361. Probed once at startup; the omnisearch search mode appears only if it answers. | derived |
MCP_TRANSPORT_TYPE | Transport: stdio or http. | stdio |
MCP_HTTP_PORT | HTTP server port. | 3010 |
MCP_SESSION_MODE | HTTP session mode: stateful, stateless, or auto. With OBSIDIAN_DELETE_ELICITATION=true and OBSIDIAN_READ_ONLY off, the server requires a stateful session, because the confirmation needs one on 2025-era clients, so stateless fails startup over HTTP. No effect on stdio. | stateful |
MCP_AUTH_MODE | Authentication: none, jwt, or oauth. | none |
MCP_LOG_LEVEL | Log level (RFC 5424). | info |
LOGS_DIR | Directory for log files (Node.js only). | <project-root>/logs |
OTEL_ENABLED | Enable OpenTelemetry. | false |
See .env.example for the full list of optional overrides.
Three optional env vars limit which vault paths the tools can touch. Unset, reads and writes cover the full vault.
| Goal | Config |
|---|---|
Read everywhere, write only in projects/ and scratch/ | OBSIDIAN_WRITE_PATHS=projects/,scratch/ |
Read only public/, write only public/inbox/ | OBSIDIAN_READ_PATHS=public/, OBSIDIAN_WRITE_PATHS=public/inbox/ |
| No writes anywhere | OBSIDIAN_READ_ONLY=true |
OBSIDIAN_READ_ONLY=true removes every write tool and the command-palette pair from tools/list, including obsidian_manage_frontmatter and obsidian_manage_tags (so their get / list go too). obsidian_open_in_ui still opens existing files but won't create one.path_forbidden, echoing the active scope in data.activeScope and the recovery hint. A denial on an active or periodic target names the target ((active file), daily note for 2026-10-01), not the note it resolved to. obsidian_list_notes lists only readable entries plus the folders leading to the scope, so a nested scope can be browsed to from the root.OBSIDIAN_READ_PATHS, searches return readable notes only, and a JSONLogic tree never reads a note outside the scope: the server evaluates it over the readable notes, with links, backlinks, and unresolvedLinks holding readable entries only. Which note a link points to is still Obsidian's resolution across the whole vault, so those three fields can change when a note outside the scope is created or removed; match link text in content when that matters. That evaluation is bounded — 1,000 levels of nesting, a work budget per note for the values a tree builds and one per search for the results it keeps, 10 s — and a tree past a bound fails with logic_too_costly. A readable note whose data is nested too deep to hand to the evaluator (about 3,500 levels under Node) fails the search with note_too_deep. Without a read scope the tree goes to the plugin unchanged.obsidian_list_tags, obsidian://tags) under OBSIDIAN_READ_PATHS cover readable notes only, counting notes rather than occurrences. Write paths and read-only alone leave them vault-wide.instructions state the effective scope: readable is the read paths plus the write paths, and writable is the write paths, or the read scope when only reads are restricted. The startup log prints the configured lists.Requires Bun v1.4.0 or higher (or Node.js v24+).
Clone and install:
git clone https://github.com/cyanheads/obsidian-mcp-server.git
cd obsidian-mcp-server && bun install
cp .env.example .env # set OBSIDIAN_API_KEY
Build and run the production version:
# One-time build
bun run rebuild
# Run the built server
bun run start:stdio
# or, listening at http://127.0.0.1:3010/mcp
bun run start:http
Run checks and tests:
bun run devcheck # Lint, format, typecheck, security, changelog sync
bun run test # Vitest test suite
bun run lint:mcp # Validate MCP definitions against spec
docker run --rm -e OBSIDIAN_API_KEY=your-key -p 3010:3010 ghcr.io/cyanheads/obsidian-mcp-server:latest
The image defaults to HTTP transport on 0.0.0.0, stateful sessions, and logs in /var/log/obsidian-mcp-server. Inside a container, the default OBSIDIAN_BASE_URL (http://127.0.0.1:27123) is the container's own loopback: point it at http://host.docker.internal:27123 (Docker Desktop) or use --network host (Linux) to reach the plugin on your host. To run it over stdio from an MCP client, use "command": "docker" with "args": ["run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "-e", "OBSIDIAN_API_KEY=your-key", "-e", "OBSIDIAN_BASE_URL=http://host.docker.internal:27123", "ghcr.io/cyanheads/obsidian-mcp-server:latest"]. To build locally, docker build -t obsidian-mcp-server .; OpenTelemetry peer dependencies are installed by default — build with --build-arg OTEL_ENABLED=false to omit them.
If the port is reachable from other machines, set MCP_AUTH_MODE=jwt (with MCP_AUTH_SECRET_KEY) or oauth. With the default none, every caller acts on your vault with your OBSIDIAN_API_KEY.
| Directory | Purpose |
|---|---|
src/index.ts | createApp() entry point: registers tools and resources, probes Omnisearch, applies the read-only and command gates. |
src/config | Server-specific environment variable parsing (OBSIDIAN_*) with Zod. |
src/services/obsidian | Local REST API client, path policy, markdown-patch format handling, frontmatter/section/tag parsing, domain types. |
src/mcp-server/tools | Tool definitions (*.tool.ts) and shared schemas and helpers. |
src/mcp-server/resources | Resource definitions (*.resource.ts). |
src/mcp-server/prompts | Prompt definitions (none registered). |
tests/ | Vitest tests for tools, resources, services, and config. |
docs/ | Local REST API OpenAPI spec and the generated tree.md. |
changelog/ | Per-version release notes; CHANGELOG.md is the regenerated rollup. |
See CLAUDE.md for development guidelines and architectural rules. The short version:
try/catch in tool logicctx.log for request-scoped logging; route every Local REST API call through getObsidianService()src/mcp-server/*/definitions/index.tsBugs, feature requests, and documentation gaps belong in an issue; see CONTRIBUTING.md for what makes one actionable and CODE_OF_CONDUCT.md for how we work together. Security reports go through SECURITY.md, never a public issue.
Run checks and tests before submitting:
bun run devcheck
bun run test
Apache-2.0 — see LICENSE for details.