Odel
Obsidian MCP Server

Obsidian MCP Server

Local
@tylern91GoApache-2.0Updated 5 days ago

Filesystem-based MCP server for Obsidian vaults — read, search, and edit notes, no app required.

obsidian-mcp-server

CI Release Go Report Card License: Apache-2.0 Go Version

A Go Model Context Protocol (MCP) server for Obsidian vaults. It gives AI agents and development tools direct filesystem access to your vault — no running Obsidian instance required.

Features

  • Read, write, and list notes and directories via MCP tools
  • Frontmatter — parse and update YAML frontmatter with format-preserving rewrites
  • Tags — extract inline #tags, aggregate vault-wide tag counts, add/remove tags
  • Backlinks — on-demand reverse link graph (wikilinks and markdown links)
  • Mutations — heading-anchored patch, safe delete, and move with confirmation guards
  • Full-text search — BM25 Okapi ranked search with match snippets
  • Regex/glob search — RE2 regex or filepath glob search across paths and content
  • Path security — 4-layer validation: lexical checks, ignore/extension filters, case-insensitive existence lookup, and symlink escape prevention
  • Stdio transport — works with any MCP client (Claude Code, Claude Desktop, etc.)
  • Streamable HTTP transport (optional) — TLS 1.3 + bearer token auth by default, loopback-only unless explicitly widened; see --transport http in Configuration and SECURITY.md § HTTP transport
  • Zero Obsidian dependency — operates on the vault directory directly
  • Token counting — responses include approximate token counts (cl100k_base)

MCP Tools

ToolDescriptionParams
read_noteRead a note's content and metadatapath (required), prettyPrint — response includes an etag
write_noteCreate or update a notepath, content (required), mode: overwrite/append/prepend, if_match (optional etag)
list_directoryList files and subdirectoriespath (empty = vault root), prettyPrint
get_frontmatterRead YAML frontmatter from a notepath (required), prettyPrint
update_frontmatterSet or remove frontmatter keys (format-preserving)path (required), updates (JSON object), removeKeys (JSON array), if_match (optional etag)
manage_tagsAdd or remove a tag on a notepath, action: add/remove (required), tag (required), location: frontmatter/inline, if_match (optional etag)
list_all_tagsAggregate all tags across the vault with countsprettyPrint
get_backlinksFind all notes that link to a target notepath (required), prettyPrint
patch_noteApply a heading-anchored patch to a notepath, heading, position: before/after/replace_body, content (all required), if_match (optional etag)
delete_noteMove a note to .obsidian-mcp/trash (requires confirm); pass permanent: true to hard-delete insteadpath, confirm (must match path exactly), permanent (optional, default false), if_match (optional etag)
move_noteMove or rename a note within the vault (requires confirm); rewrites unambiguous inbound links by defaultsrc, dst, confirm (must match src exactly), updateLinks (bool, default true), dryRun (bool, default false), if_match (optional etag; ignored when dryRun is true)
search_notesBM25 full-text search with ranked results and match snippetsquery (required), limit, maxMatchesPerFile, caseSensitive, searchContent, searchFrontmatter, pathScope, prettyPrint
search_regexSearch using RE2 regex or glob patternpattern (required), isGlob, scope, limit, maxMatchesPerFile, prettyPrint
read_multiple_notesRead the content of multiple notes in a single requestpaths (required, JSON array), summary (bool, default false), headChars (int, default 200) — each entry includes an etag
get_notes_infoGet metadata for multiple notes without reading full contentpaths (required, JSON array) — each entry includes an etag
get_vault_statsGet aggregate statistics about the entire vaultincludeTokenCounts (bool, default false)
get_periodic_noteGet a periodic note (daily, weekly, monthly, quarterly, or yearly)granularity (required, enum: daily/weekly/monthly/quarterly/yearly), offset (int, default 0), createIfMissing (bool, default false)
get_recent_periodic_notesGet the N most recent periodic notesgranularity (required, enum: daily/weekly/monthly/quarterly/yearly), count (int, default 5), summary (bool, default true)
get_recent_changesList notes most recently modified in the vaultlimit (int, default 10), since (string, ISO-8601), summary (bool, default true)
audit_notesAudit the vault for hygiene issues: orphans, dangling links, untagged notes, duplicate titlesclasses (JSON array: orphans/dangling-links/untagged/duplicate-titles, default all), limit (int per class, default 20)
get_note_outlineReturn a note's heading tree (level, text, line number) without its bodypath (required), prettyPrint
read_note_linesRead a bounded range of lines from a notepath, startLine (required), lineCount (int, default 200, capped at 2000)
rename_tagRename a tag vault-wide, across frontmatter and inline occurrencesoldTag, newTag (both required)
replace_in_noteScoped search-and-replace within a single note, literal or regexpath, pattern, replacement (all required), isRegex (bool, default false), maxOccurrences (int, default 0 = unbounded)

search_notes, search_regex, list_directory, and get_recent_changes results also include an obsidian://open deepLink field per note, built from --vault-name (see Configuration below).

Notes

patch_note semantics: position controls where content is inserted relative to the heading:

  • before — inserted immediately before the heading line
  • after — inserted after the heading's body (before the next same-level or higher heading)
  • replace_body — replaces everything between the heading line and the next same-level heading

search_notes parameters:

ParameterTypeDefaultDescription
querystringrequiredSearch query. Multi-term queries use OR logic; the full phrase contributes a bonus score.
limitinteger20Maximum number of results
maxMatchesPerFileinteger3Maximum match snippets per result
caseSensitivebooleanfalseCase-sensitive matching
searchContentbooleantrueInclude note body in scoring
searchFrontmatterbooleantrueInclude frontmatter values in scoring
pathScopestringGlob pattern to restrict search scope (e.g. Daily Notes/*)
prettyPrintbooleanfalseFormat JSON with indentation

Returns: { query, results: [{ path, score, matchCount, matches: [{line, snippet, term}], tokenCount, reason }], total }

search_regex parameters:

ParameterTypeDefaultDescription
patternstringrequiredRE2 regex or glob pattern
isGlobbooleanfalseTreat pattern as a filepath glob (** matches across dirs)
scopestringcontentpath, content, or both
limitinteger20Maximum number of results
maxMatchesPerFileinteger5Maximum match snippets per result
prettyPrintbooleanfalseFormat JSON with indentation

Returns: { pattern, scope, results: [{ path, matches: [{line, snippet}] }], total }

Batch tools (read_multiple_notes, get_notes_info): The paths parameter is a JSON array string — e.g. '["Notes/foo.md","Notes/bar.md"]'. summary:true returns headOf (first N runes from headChars, default 200) instead of full content, which is useful for large notes to stay within context limits. Both tools enforce --max-batch (default 10); requests with more paths are silently truncated and the response includes "truncated": true.

Optimistic concurrency (if_match / etag): read_note, read_multiple_notes, and get_notes_info return a SHA-256 etag of the note's content. Pass that value as if_match on write_note, patch_note, update_frontmatter, manage_tags, delete_note, or move_note to make the write conditional — if the note has changed since you read it, the call fails with a REVISION_CONFLICT error instead of silently overwriting someone else's edit. if_match is optional everywhere; omitting it writes unconditionally, as before. Two edge cases: passing if_match for a note that doesn't exist yet is always a conflict (it never creates the note), and move_note's dryRun:true preview does not enforce if_match.

Periodic notes (get_periodic_note, get_recent_periodic_notes): Configuration (folder and date format per granularity) is read from .obsidian/plugins/periodic-notes/data.json inside the vault. If that file is missing, built-in defaults are used: daily notes use YYYY-MM-DD in Daily Notes/, weekly notes use gggg-[W]ww in Weekly Notes/, and so on. offset=0 resolves to the current period, offset=-1 to the previous period (yesterday, last week, etc.), and offset=+1 to the next period. createIfMissing=true creates an empty note at the resolved path if it does not already exist.

get_vault_stats: Returns noteCount, totalBytes, totalLinks, totalTags, topTags (top 20 by count), oldestNote, newestNote, and vaultRoot. Setting includeTokenCounts:true runs token counting across every note — this is expensive for large vaults and is disabled by default.

audit_notes classes:

  • orphans — notes that have no tags AND no incoming wikilinks or markdown links (completely isolated notes)
  • dangling-links — notes containing links to vault paths that do not exist (broken references)
  • untagged — notes with no frontmatter tags and no inline #tags
  • duplicate-titles — multiple notes sharing the same filename stem, which causes wikilink ambiguity

Each class result is capped at limit entries (default 20). When results are truncated, the response includes "truncated": true.

MCP Prompts

Prompts are server-defined conversation starters that the host (Claude Code, Claude Desktop) exposes in its UI. Each prompt pulls live vault data and constructs a ready-to-use message for the LLM.

PromptDescriptionArguments
summarize_noteSummarize a note: 3 key bullets, entities, open questionspath (required)
daily_note_reviewReview a daily note: carryover TODOs, link suggestions, missing tagsoffset (int, default 0)
weekly_reviewWeekly retrospective from the last 7 daily notesweekOffset (int, default 0)
find_relatedSuggest related notes worth linking, grouped by relationship typepath (required)
vault_health_checkAudit orphans, dangling links, untagged notes, duplicate titles; prioritize fixes(none)

Prompts are invoked from the host's prompt picker (e.g. / in Claude Code). They never modify the vault.

MCP Resources

Resources are read-only vault data that the host can attach directly to a conversation context window — no explicit tool call required.

Resource / TemplateURIMIMEDescription
Vault statisticsobsidian://vault/statsapplication/jsonNote count, total size, top 10 tags, vault root
Tag indexobsidian://vault/tagsapplication/jsonAll tags with note counts, sorted by frequency
Note contentobsidian://note/{path}text/markdownRaw markdown (frontmatter + body) for any vault note
Periodic noteobsidian://periodic/{granularity}text/markdownCurrent daily / weekly / monthly / quarterly / yearly note
Backlinksobsidian://backlinks/{path}application/jsonAll notes linking to the target, with line numbers and snippets

Static resources (obsidian://vault/*) are always available in the resource picker. Template resources are resolved when the host reads them — if the note does not exist, the resource returns an explanatory empty body instead of an error.

Installation

Install script

Downloads the right binary for your OS/architecture, verifies its checksum, and installs it to ~/.local/bin (override with a second argument):

curl -fsSL https://raw.githubusercontent.com/tylern91/obsidian-mcp-server/main/install.sh | sh

Pin a version instead of latest:

curl -fsSL https://raw.githubusercontent.com/tylern91/obsidian-mcp-server/main/install.sh | sh -s -- v0.2.0

Claude Desktop (.mcpb bundle)

Download obsidian-mcp-<version>.mcpb from the latest release and double-click it — Claude Desktop installs the extension and prompts for your vault path. No manual JSON editing.

Release binary

Download a prebuilt binary from the latest release, verify its checksum, and install:

# macOS (Apple Silicon) — swap the asset name for your platform (darwin-amd64, linux-amd64, linux-arm64)
curl -fLO https://github.com/tylern91/obsidian-mcp-server/releases/latest/download/obsidian-mcp-<version>-darwin-arm64.tar.gz
curl -fLO https://github.com/tylern91/obsidian-mcp-server/releases/latest/download/obsidian-mcp-<version>-darwin-arm64.tar.gz.sha256
shasum -a 256 -c obsidian-mcp-<version>-darwin-arm64.tar.gz.sha256
tar -xf obsidian-mcp-<version>-darwin-arm64.tar.gz
install -m 0755 obsidian-mcp-<version>-darwin-arm64/obsidian-mcp ~/.local/bin/obsidian-mcp

Homebrew (macOS/Linux)

brew tap tylern91/obsidian-mcp
brew install obsidian-mcp

go install

Requires Go 1.27+ to build from source. Building with GOTOOLCHAIN=local on an older Go requires upgrading first — GOTOOLCHAIN=auto (the default since Go 1.21) downloads a matching toolchain automatically.

go install github.com/tylern91/obsidian-mcp-server/cmd/obsidian-mcp@latest

Build from source

Requires Go 1.27+. Building with GOTOOLCHAIN=local on an older Go requires upgrading first — GOTOOLCHAIN=auto (the default since Go 1.21) downloads a matching toolchain automatically.

git clone https://github.com/tylern91/obsidian-mcp-server.git
cd obsidian-mcp-server
make build

Usage

Claude Code

claude mcp add obsidian -s user \
  -e OBSIDIAN_VAULT_PATH="/path/to/your/vault" \
  -- obsidian-mcp

Claude Desktop

Add to claude_desktop_config.json:

{
  "mcpServers": {
    "obsidian": {
      "command": "obsidian-mcp",
      "args": ["--vault", "/path/to/your/vault"],
      "env": {}
    }
  }
}

Cursor

Install MCP Server

Edit the vault path after install — the deeplink can't know it in advance.

VS Code

Install in VS Code Install in VS Code Insiders

Edit the vault path after install — the deeplink can't know it in advance.

Direct

obsidian-mcp --vault /path/to/your/vault

Print the version and exit:

obsidian-mcp --version

Configuration

Configuration follows CLI flag > environment variable > default precedence.

FlagEnv VarDefaultValid values
--vaultOBSIDIAN_VAULT_PATH(required)Absolute or relative path to an existing directory. Validated at startup — non-existent paths or files (not dirs) cause an immediate error. Surrounding whitespace is trimmed.
--versionPrints the binary version to stdout and exits. Does not require --vault.
--extensionsOBSIDIAN_EXTENSIONS.md,.markdown,.txt,.canvasComma-separated list. Each entry should start with . (e.g. .md). Whitespace around entries is trimmed; empty entries are discarded. Only files matching one of these extensions are visible to MCP tools.
--ignoreOBSIDIAN_IGNORE.obsidian,.git,node_modules,.DS_Store,.trashComma-separated list of file/directory names to skip during traversal. Match is by name (not glob). Whitespace trimmed; empties discarded.
--prettyOBSIDIAN_PRETTYfalseCLI: bare --pretty enables it. Env var: any value accepted by Go's strconv.ParseBool1, t, T, true, TRUE, True, 0, f, F, false, FALSE, False. Anything else causes a startup error.
--max-batchOBSIDIAN_MAX_BATCH10Integer ≥ 1. Non-integer or <1 causes a startup error. Caps the number of files processed in a single batch tool call (Phase 4). High values increase memory usage and token count per response — very large batches can overflow an AI client's context window and slow down individual tool calls. Keep at or near the default unless your vault files are small.
--max-resultsOBSIDIAN_MAX_RESULTS20Integer ≥ 1. Non-integer or <1 causes a startup error. Caps the number of search results returned. High values increase response token count — returning hundreds of results per search can exhaust the AI client's context window with low-relevance entries. Increase only when precision-recall trade-offs require broader result sets.
--log-levelOBSIDIAN_LOG_LEVELwarnOne of: debug, info, warn, error (lowercase, case-sensitive). Unknown values silently fall back to warn — no error, no warning logged.
--read-onlyOBSIDIAN_READ_ONLYfalseCLI: bare --read-only enables it. Env var: same ParseBool rules as --pretty. When enabled, mutating tools (write_note, delete_note, move_note, etc.) are not registered — they never appear in tools/list.
--trash-retention-daysOBSIDIAN_TRASH_RETENTION_DAYS30Integer ≥ 0. Non-integer or negative causes a startup error. delete_note moves notes to .obsidian-mcp/trash/<timestamp>/<path> by default instead of hard-deleting; entries older than this many days are pruned once at startup.
--vault-nameOBSIDIAN_VAULT_NAMEthe vault directory's basenameUsed to build the obsidian://open?vault=<name>&file=<path> deep links in search_notes, search_regex, list_directory, and get_recent_changes results. Set explicitly if the vault directory's name doesn't match the name Obsidian shows for it.
--transportOBSIDIAN_TRANSPORTstdiostdio or http. http starts a TLS-secured Streamable HTTP listener instead of speaking MCP over stdio — see SECURITY.md § HTTP transport.
--http-bindOBSIDIAN_HTTP_BIND127.0.0.1Bind address for --transport http. Non-loopback addresses are refused unless --allow-non-loopback is also set.
--http-portOBSIDIAN_HTTP_PORT8443Port for --transport http.
--allow-non-loopbackOBSIDIAN_ALLOW_NON_LOOPBACKfalseAllows --http-bind to a non-loopback address. Requires non-empty --allowed-hosts and --allowed-origins — an explicit, three-flag confirmation gate.
--allowed-hostsOBSIDIAN_ALLOWED_HOSTS(empty)Comma-separated Host header allowlist for --transport http. Required with --allow-non-loopback.
--allowed-originsOBSIDIAN_ALLOWED_ORIGINS(empty)Comma-separated Origin header allowlist for --transport http. Required with --allow-non-loopback.
--client-caOBSIDIAN_CLIENT_CA(none)Path to a PEM file of trusted client CAs. Enables mandatory mutual TLS for --transport http — connections without a valid client certificate are rejected.

Examples

# Override extensions to include Excalidraw drawings
obsidian-mcp --vault ./my-vault --extensions ".md,.canvas,.excalidraw"

# Add a custom ignore pattern alongside defaults (you must repeat the defaults
# you want to keep — values fully replace, not merge)
obsidian-mcp --vault ./my-vault \
  --ignore ".obsidian,.git,node_modules,.DS_Store,.trash,Archive,Templates"

# Enable pretty JSON via env var (any ParseBool-compatible truthy value works)
OBSIDIAN_PRETTY=1 obsidian-mcp --vault ./my-vault
OBSIDIAN_PRETTY=true obsidian-mcp --vault ./my-vault

# Verbose logging while debugging an integration
OBSIDIAN_LOG_LEVEL=debug obsidian-mcp --vault ./my-vault

# Streamable HTTP transport instead of stdio (loopback only, TLS + bearer
# token auto-generated on first run — see SECURITY.md § HTTP transport)
obsidian-mcp --vault ./my-vault --transport http --http-port 8443

Precedence in action: with OBSIDIAN_LOG_LEVEL=debug exported, obsidian-mcp --vault ... --log-level info runs at info — the explicit flag wins. Unset flags inherit the env var; if neither is set, the default applies.

Security

All paths are validated through a 4-layer security model before any filesystem operation:

  1. Lexical — rejects absolute paths, .. traversal, and null bytes
  2. Filter — blocks ignored patterns (.git, .obsidian, etc.) and unapproved extensions
  3. Existence — verifies the file exists with a case-insensitive fallback; rejects ambiguous matches
  4. Symlink — resolves symlinks and verifies the target remains inside the vault root

The optional --transport http listener has its own security posture (TLS, bearer auth, loopback-only default, session binding) — see SECURITY.md § HTTP transport.

Project Structure

cmd/obsidian-mcp/     Entry point, transport selection (stdio/http)
internal/
  config/             CLI flags, env vars, defaults
  vault/              Path security, CRUD, frontmatter, tags, links, mutations
  tools/              MCP tool registrations and handlers
  response/           Token counting, JSON formatting
  search/             BM25 ranked search, regex/glob
  periodic/           Periodic note resolution (Phase 4)
  prompts/            MCP Prompt templates
  resources/          MCP Resource registrations
  httptransport/      Streamable HTTP transport: TLS, bearer auth, session binding
testdata/vault/       Fixture vault for tests

Development

make build    # compile binary
make test     # go test -race ./...
make vet      # go vet ./...
make fmt      # gofmt + goimports
make run ARGS="--vault /path/to/vault"
make help     # list all targets

License

Apache-2.0