Read, write, search, and surgically edit Obsidian vault notes, tags, and frontmatter via MCP. STDIO or Streamable HTTP.
Obsidian vault notes over the Local REST API plugin. Read, search, and write notes, edit single headings, blocks, and frontmatter fields in place, and manage tags, with folder-scoped read/write permissions built in. Runs as a stdio process or a local Streamable HTTP server.
| 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.
targetis a vaultpath, theactivefile, or aperiodicnote (dailythroughyearly, optionaldate);formatiscontent,full,document-map, orsection, andfulltakesincludeLinks: truefor vault-internal outgoing linksresult.formatdiscriminates the payload; asectionread that matches several headings returns the first and lists every full path incandidates- A
pathwith no exact match but one case-insensitive match in its folder reads that file:result.pathis the real name,requestedPaththe one sent, and anoticenames both
- Walks from
path(default vault root) todepth1–20 (default 2), filtered byextensionandnameRegex(≤256 chars); a folder that failsnameRegexis not walked - Returns
entries[](file/directory),totals, andappliedFilters; the walk stops at 1,000 entries withexcluded.reason: "entry_cap", and a folder the depth limit stopped carriestruncated: true - With
OBSIDIAN_READ_PATHSset, a listing holds only readable entries and the folders leading to the scope; those folders are walked and accepted aspath
nameRegex(≤256 chars) andminCountnarrow the set, then tags are ranked by count and capped atlimit(default 200, max 10000); hierarchical parents count (work/tasksadds towork)- When the cap withholds tags, the response carries
truncated,shown, andcap - With
OBSIDIAN_READ_PATHSset, only tags from readable notes are listed, andcountis the number of readable notes carrying the tag or a tag nested under it
mode: "text"requires every whitespace-split token ofqueryas a case-insensitive substring of the filename or body (quotes are literal), shaped bycontextLength(default 100),pathPrefix, andmaxMatchesPerHit(default 10);mode: "jsonlogic"evaluates alogictree overpath,content,frontmatter.<key>,tags, andstat, withglob/regexptaking[PATTERN, VALUE]result.modediscriminates the payload; every mode reportstotalCountand pages vianextCursor, and a text hit clipped tomaxMatchesPerHitcarriestruncatedandtotalMatchesmode: "omnisearch"(BM25 ranking, quoted phrases,-exclusion,path:/ext:filters) is offered only when the Omnisearch plugin answered at startup; its 50-hit upstream cap setstruncated: true
targetandcontent, with optionalsectionandcontentType(markdown/json); a whole-file write to an existing note fails withfile_existsunlessoverwrite: true- With
section, replaces only that heading, block, or frontmatter field and keeps the heading line; output reportscreated,sectionTargeted, and the resolvedsectionTarget
- Without
section, appends to the file or creates it (created: true); withsection, appends to that heading, block, or frontmatter field of an existing note, andcreateTargetIfMissing: truecreates the section - A section append whose content is already at the target fails with
content_preexists; block targets add no separator, so startcontentwith a newline if you want one
operation: "append" | "prepend" | "replace"against onesectionof an existing note;patchOptionstakescreateTargetIfMissing,applyIfContentPreexists, andtrimTargetWhitespace(plugin v4.x only)- Echoes the resolved
sectionandoperation; a repeat of content already at the target fails withcontent_preexistsunlessapplyIfContentPreexists: true
replacements[]run in order, each over the previous one's output; each takesuseRegex(≤1024 chars),caseSensitive(defaulttrue),wholeWord,flexibleWhitespace(literal mode only), andreplaceAll(defaulttrue)- Returns
totalReplacementsandperReplacement[]withbodyCount/frontmatterCount scope: "body"(default) leaves frontmatter byte-identical;"frontmatter"and"both"re-parse the YAML afterward and write nothing if it breaks (frontmatter_invalid)
operation: "get" | "set" | "delete"on onekey;setrequires a JSON-typedvaluegetreturnsexistsandvalue(nullwhen absent);setanddeletereturn the fullfrontmatterafter the change, and adeleteagainst unparseable YAML fails withfrontmatter_invalidwithout writing
operation: "add" | "remove" | "list"withtags;location: "frontmatter"(default, thetags:array),"inline"(body#tag;addappends at end of file), or"both"add/removereportapplied,skipped, and the resultingtags;listreturnsfrontmatter,inline, andall- Inline detection follows Obsidian's tag grammar: code, wikilinks, images, link destinations, HTML, and math are skipped, and
%% … %%comments are read
- Takes a
target(path, active file, or periodic note) and checks it against the write scope first; a folder path fails withpath_is_directory. There is no API-level undo, only Obsidian's local trash - The path must match exactly, letter case included, even on a case-insensitive filesystem. A different-case or extension-less spelling fails
note_missingbefore anything is deleted, naming the near matches insuggestions - By default the note is deleted on the first call, with no confirmation;
OBSIDIAN_WRITE_PATHSandOBSIDIAN_READ_ONLYbound what it can reach - With
OBSIDIAN_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 withcancelled, and a client without elicitation support cannot delete - In that mode an answer counts only against the single-use consent record stored when the prompt was shown, bound to the caller, the path, and the note's content then. A pre-supplied or replayed answer, or one given after the note changed, gets a fresh prompt instead
- Consent records live in the server's storage provider (
STORAGE_PROVIDER_TYPE, defaultin-memory, process-local). That works for stdio or a single HTTP instance; several instances behind one endpoint need a shared provider (filesystem,supabase, orcloudflare-d1, nevercloudflare-kv)
path,failIfMissing(defaulttrue), andnewLeaf(open in a split pane); withfailIfMissing: falsea missing file is created, which needs write accesscreatedIfMissingreports which branch ran- A case-mismatched
pathwith one case-insensitive match opens that file instead of creating another, reporting the path sent asrequestedPathwith anotice
- Optional
nameRegex(≤256 chars) matched against each command's display name - Returns
commands[]ofidandname, whereidfeedsobsidian_execute_command - Listed only when
OBSIDIAN_ENABLE_COMMANDS=trueandOBSIDIAN_READ_ONLYis off
commandIdfromobsidian_list_commands; returnsexecuted: true, or fails withcommand_unknownfor an unregistered ID- Runs with the authority of a keyboard shortcut, so some commands are destructive (delete file, close vault); gated like
obsidian_list_commands
{+path}captures everything after/vault/, slashes included; literal and percent-encoded paths resolve to the same note- Returns
path,content,frontmatter,tags, andstat, the same shape asobsidian_get_notewithformat: "full"; failures arepath_forbidden,note_missing, orpath_is_directory
- Every tag with its
count, uncapped and unsorted, hierarchical parents included - With
OBSIDIAN_READ_PATHSset, only tags from readable notes, counted the same way asobsidian_list_tags - No ranking or filters;
obsidian_list_tagsgives the count-ranked, capped view
status,service,authenticated,versions,manifest, andapiExtensions[]; still answers with a wrong API key, reportingauthenticated: false- On plugin v5.0.2 and later, check
apiExtensionsforlocal-rest-api-periodic-notesbefore usingperiodictargets
Built 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:
- Typed client for the Obsidian Local REST API plugin; section writes speak markdown-patch 2.0 to plugin v5.0 and later and 1.x to v4.x, chosen from the reported plugin version
- Heading targets take a full
Parent::Childpath or a bare leaf name; writes reject an ambiguous one withambiguous_sectionand itscandidates, 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) - Folder-scoped read/write permissions, a read-only switch, and an opt-in command-palette pair (see Path policy); server-level
instructionsoninitializereport the active policy obsidian_get_noteandobsidian_open_in_uiretry a case-mismatched path against the real filename, disclosed asrequestedPathplus anotice, and addDid you meansuggestions to a miss; writes and deletes match the exact path- Regex inputs are capped (
nameRegexat 256 chars,useRegexat 1024) and rejected withregex_unsafewhen they nest quantifiers - Backlinks have no dedicated tool;
obsidian_search_notesinjsonlogicmode finds them with{"regexp": ["\\[\\[Target Note(\\\\?\\||#|\\]\\])", {"var": "content"}]}, which also matches the table-safe[[Target Note\|Alias]]
Agent-friendly output:
- Recovery-guided errors: every declared failure carries a
reason, a JSON-RPC code, and arecovery.hintwritten for that case - Size deltas: every mutating tool returns
previousSizeInBytes/currentSizeInBytes, so a caller can spot an accidental clobber without a follow-up read - Ambiguity surfaced as data: shared heading names return
candidates, and tag operations reportappliedvs.skipped - Discriminated output contracts:
formatonobsidian_get_note,modeonobsidian_search_notes,operationonobsidian_manage_frontmatterandobsidian_manage_tags
Add the following to your MCP client configuration file. The Obsidian Local REST API plugin must be installed and enabled in your vault; see Prerequisites.
{
"mcpServers": {
"obsidian-mcp-server": {
"type": "stdio",
"command": "bunx",
"args": ["obsidian-mcp-server@latest"],
"env": {
"MCP_TRANSPORT_TYPE": "stdio",
"MCP_LOG_LEVEL": "info",
"OBSIDIAN_API_KEY": "your-local-rest-api-key"
}
}
}
}Or with npx (no Bun required):
{
"mcpServers": {
"obsidian-mcp-server": {
"type": "stdio",
"command": "npx",
"args": ["-y", "obsidian-mcp-server@latest"],
"env": {
"MCP_TRANSPORT_TYPE": "stdio",
"MCP_LOG_LEVEL": "info",
"OBSIDIAN_API_KEY": "your-local-rest-api-key"
}
}
}
}Or with Docker:
{
"mcpServers": {
"obsidian-mcp-server": {
"type": "stdio",
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "MCP_TRANSPORT_TYPE=stdio",
"-e", "MCP_LOG_LEVEL=info",
"-e", "OBSIDIAN_API_KEY=your-local-rest-api-key",
"ghcr.io/cyanheads/obsidian-mcp-server:latest"
]
}
}
}Inside a container, the default OBSIDIAN_BASE_URL (http://127.0.0.1:27123) is the container's own loopback. Add -e OBSIDIAN_BASE_URL=http://host.docker.internal:27123 (Docker Desktop) or run with --network host (Linux) to reach the plugin on your host.
For Streamable HTTP, set the transport and start the server:
MCP_TRANSPORT_TYPE=http OBSIDIAN_API_KEY=... bun run start:http
# Server listens at http://127.0.0.1:3010/mcp by default- Bun v1.4.0 or higher (or Node.js v24+).
- The Obsidian Local REST API plugin, v4.0.0 or later, enabled in your vault. Generate an API key under Settings → Community Plugins → Local REST API and set it as
OBSIDIAN_API_KEY. - The server defaults to
http://127.0.0.1:27123, so enable "Non-encrypted (HTTP) Server" in the plugin settings, or setOBSIDIAN_BASE_URL=https://127.0.0.1:27124for the always-on HTTPS port (its self-signed cert is accepted whileOBSIDIAN_VERIFY_SSL=false, the default). - Periodic-note targets work natively on plugin v5.0.1 and earlier. From v5.0.2 they need the periodic-notes API extension; without it they fail with
periodic_unsupported. - On plugin v4.x, heading section writes to a note with CRLF (Windows) line endings land mid-line: the plugin's markdown-patch 1.x engine misplaces them, and the server can't correct it. Plugin v5.0 and later handle CRLF notes.
- Plugin v6.0 drops markdown-patch 1.x, which two table-row writes (
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. - With
OBSIDIAN_DELETE_ELICITATION=true, an MCP client that supports elicitation and renders its form, to useobsidian_delete_note. Every other tool, and the default delete, works without it.
-
Clone the repository:
git clone https://github.com/cyanheads/obsidian-mcp-server.git
-
Navigate into the directory:
cd obsidian-mcp-server -
Install dependencies:
bun install
-
Configure environment:
cp .env.example .env # edit .env and set OBSIDIAN_API_KEY
| 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 (full vault) |
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 |
- Matching is by prefix, recursive, and case-insensitive; trailing slashes are normalized. Write paths are also readable.
OBSIDIAN_READ_ONLY=trueremoves every write tool and the command-palette pair fromtools/list, includingobsidian_manage_frontmatterandobsidian_manage_tags(so theirget/listgo too).obsidian_open_in_uistill opens existing files but won't create one.- A denial fails with
path_forbidden, echoing the active scope indata.activeScopeand the recovery hint. Search hits outside the read scope are dropped silently, andobsidian_list_noteslists only readable entries plus the folders leading to the scope, so a nested scope can be browsed to from the root. - Tag listings (
obsidian_list_tags,obsidian://tags) underOBSIDIAN_READ_PATHScover readable notes only, counting notes rather than occurrences. Write paths and read-only alone leave them vault-wide. - The startup log prints the active scope.
-
Build and run the production version:
# One-time build bun run rebuild # Run the built server bun run start:stdio # or 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 build -t obsidian-mcp-server .
docker run --rm -e OBSIDIAN_API_KEY=your-key -p 3010:3010 obsidian-mcp-serverThe image defaults to HTTP transport on 0.0.0.0, stateful sessions, and logs in /var/log/obsidian-mcp-server. Point OBSIDIAN_BASE_URL at http://host.docker.internal:27123 (Docker Desktop) or use --network host (Linux) to reach the plugin. 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:
- Handlers throw, framework catches — no
try/catchin tool logic - Use
ctx.logfor request-scoped logging; route every Local REST API call throughgetObsidianService() - Register new tools and resources via the barrels in
src/mcp-server/*/definitions/index.ts - Wrap external API calls: validate raw → normalize to domain type → return output schema; never fabricate missing fields
Bugs, 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 testApache-2.0 — see LICENSE for details.