Obsidian MCP Server
An Obsidian plugin that runs an embedded MCP server inside Obsidian, exposing vault operations and optional GitHub integration to any MCP client.
README
Obsidian MCP Server
An Obsidian plugin that runs an embedded Model Context Protocol server (Streamable HTTP) inside Obsidian, exposing your vault — plus an optional GitHub integration — to any MCP client (Claude Desktop, Claude Code, the MCP Inspector, etc.).
Written from scratch against the MCP TypeScript SDK; it does not reuse any code from obsidian-local-rest-api, though it targets a superset of that plugin's MCP capabilities (see "What this adds" below).
What's verified vs. what's assumed
This was built in an environment with no real Obsidian install, so nothing here was tested inside an actual vault. What is verified, and how:
| Claim | How it was checked |
|---|---|
The whole project compiles under TypeScript strict mode |
npx tsc --noEmit — exits 0, and a deliberately-broken canary line was confirmed to actually fail the check first (so this isn't a silently-noop config) |
esbuild produces a valid Obsidian plugin bundle |
npm run build → inspected main.js (654KB, correct banner, require("obsidian")/require("electron") left external as expected, Node built-ins (node:http, node:crypto, node:events) resolved) |
| All 21 tools, the resource template, and all 3 prompts register with correct names/schemas/annotations; pagination, URI encoding, path validation, batch partial-failure, and embedding-response validation behave as documented | npm run verify → verify-tools.ts — spins up the real McpServer from src/server/mcpServer.ts against a mocked App (see scripts/obsidian-mock.mjs), connects a real MCP Client over an in-memory transport pair, and makes real listTools/listResources/listPrompts/callTool/readResource calls with assertions (not just printed output). This validates the MCP-protocol layer; it does not validate real Obsidian API behavior (see below). |
StreamableHTTPServerTransport.handleRequest works against a bare node:http server (no Express); auth, method/Host-header rejection, EADDRINUSE handling, and safe double-close all behave correctly |
npm run verify → verify-http.ts — a real, unmocked integration test (neither httpTransport.ts nor auth.ts imports obsidian) that starts real servers on a real port and sends real HTTP requests, including deliberately triggering a real port conflict |
| The restart-race fix actually prevents the server from ending up stopped when it should be running | npm run verify → verify-restart.ts — instantiates the real ObsidianMcpPlugin class (mocked App, real HTTP port) and fires concurrent restartServer()/startServer()/stopServer() calls, asserting the server is reachable/unreachable at each expected point |
The note-path validator rejects absolute paths, .. traversal, .obsidian/ paths, and non-.md paths, while leaving normal paths untouched |
npm run verify → verify-notepath.ts — a real, unmocked unit test (notePathValidation.ts has no obsidian import) |
Not verified — you need to do this yourself, in a real vault:
- Loading the plugin in Obsidian, enabling it, and confirming the settings tab renders correctly
- All Obsidian API calls (
app.vault.*,app.metadataCache.*,app.fileManager.processFrontMatter,app.workspace.getActiveFile) against real files — the mock inscripts/obsidian-mock.mjsonly stubs 2-3 methods well enough to smoke-test tool registration, it is not a faithful vault obsidian_run_commandandobsidian_get_periodic_note, which both use undocumented internal Obsidian APIs (app.commands,app.internalPlugins) not present in the publicobsidian.d.tsat all — these are common patterns in the plugin ecosystem, but they can break across Obsidian versions without notice- The Dataview integration (
obsidian_dataview_query), which similarly readsapp.plugins.plugins.dataview.apivia an internal-API cast - Real GitHub API calls (never run in this session — no PAT was used, no requests were made)
- The embedding-search code path (never called an actual embedding endpoint)
What this adds over the baseline reference
- Event-driven cache: a TF-IDF search index kept current via
vault.on('modify'|'create'|'delete'|'rename')instead of rescanning the vault per search. - Batch tools:
obsidian_batch_read/obsidian_batch_writefor up to 200 notes per call. - Local semantic search with no external dependency (TF-IDF cosine similarity), plus an optional OpenAI-compatible embedding re-ranking mode — off by default, see Security below.
- Graph awareness: backlinks, outgoing links, and a vault-wide tag graph via
metadataCache. - Dataview integration, gracefully absent (not erroring) when Dataview isn't installed.
- Full MCP surface: tools, a
resourcestemplate (obsidian://vault/{path}), and 3prompts— not tools only. - GitHub integration: read/write files, list repo contents, create/list issues, get repo info.
Requirements
- Obsidian desktop (this plugin is
isDesktopOnly: true— it uses Node'snode:http/node:crypto, unavailable on mobile) - Node.js ≥ 18 only for building the plugin from source; end users just copy the built files into their vault
Installing (from source, on your own machine)
git clone <your-fork-url> obsidian-mcp-server
cd obsidian-mcp-server
npm install
npm run build
This produces main.js at the project root, alongside manifest.json. Then, in your vault:
mkdir -p "<YourVault>/.obsidian/plugins/obsidian-mcp-server"
cp main.js manifest.json versions.json "<YourVault>/.obsidian/plugins/obsidian-mcp-server/"
In Obsidian: Settings → Community plugins → disable Restricted mode (if on) → reload plugins → enable "MCP Server".
Configuring
Open Settings → MCP Server:
| Setting | Notes |
|---|---|
| Port | Default 27123. Restart the server (see below) after changing. |
| Bind host | Default 127.0.0.1 (localhost-only). 0.0.0.0 exposes it to your network and disables the built-in Host-header check — only do this on a trusted network. |
| Auth token | Required. The server refuses to start without one. Clients must send Authorization: Bearer <token>. |
| Enable embedding search | Off by default. Sends candidate note text to the embedding endpoint below on every embedding-mode search. Local semantic (TF-IDF) mode always works offline regardless of this toggle. |
| Embedding endpoint / API key / model | An OpenAI-compatible /embeddings endpoint. |
| GitHub Personal Access Token | Required for all github_* tools. |
| GitHub default owner/repo | Used when a github_* tool call omits owner/repo. |
Settings changes are saved immediately but do not hot-reload the running server (so typing a token doesn't restart it on every keystroke). Run the command "Restart MCP server" from the Command Palette, or disable/re-enable the plugin, after changing port/host/token.
Restart behavior and error messages
Start/stop/restart are serialized through an internal lock, so triggering "Restart MCP server" more than once in a row (or while the plugin is still starting up) can't interleave and leave the server stopped when it should be running, or vice versa — each operation waits its turn and runs against the actual current state.
Startup failures are reported clearly instead of silently: the plugin waits for the HTTP server to actually start listening (or fail) before showing any Notice.
- Port already in use: "Port 27123 is already in use on 127.0.0.1. Another process (or another copy of this plugin) is already listening there — pick a different port in plugin settings, or stop whatever else is using it."
- Permission denied (binding to a port below 1024 without elevated privileges): a similar explicit message suggesting a port above 1024.
- Restart failure: shown as "MCP Server: restart failed — <reason>".
close()-ing an already-stopped or never-started server is safe and does not throw.
Security notes (read this before using GitHub or embedding features)
- The auth token and the GitHub PAT are stored in plain text in this plugin's
data.json(.obsidian/plugins/obsidian-mcp-server/data.json). Obsidian has no encrypted secret storage available to community plugins. Anyone with filesystem access to that folder can read both. Use a fine-grained GitHub PAT scoped to only the repos you intend this plugin to touch. - The server binds to
127.0.0.1by default. Only widen this (0.0.0.0) on networks you trust — it also disables the Host-header DNS-rebinding check described below. - DNS-rebinding protection is hand-rolled, not from a framework. Because this project deliberately avoids
Express (see Architecture below), there's no
createMcpExpressApp()to lean on.src/server/httpTransport.tsrejects any request whoseHostheader isn't127.0.0.1,localhost, or the configured bind host — unless the bind host is0.0.0.0, in which case you've opted out. - Embedding search (off by default) sends note text to whatever endpoint you configure — capped at the first 8000 characters of each of up to 50 candidate notes per query, with a 15-second timeout (best-effort: it stops waiting on a slow endpoint, it does not cancel the in-flight request) and strict validation of the response shape (right number of embeddings, all numeric, consistent dimensions) before any result is trusted. Keyword and semantic (TF-IDF) search never leave your machine.
obsidian_delete_note,obsidian_write_note(full overwrite),obsidian_run_command,github_create_issue, and the othergithub_*write tools are all markeddestructiveHint: truein their MCP annotations — a well-behaved MCP client should surface that to the user before calling them, but don't rely on it as your only safety net.- Note tools only accept vault-relative
.mdpaths. Every read/write/delete/rename/patch/batch operation is validated before it touches the filesystem: empty paths, absolute paths,..traversal segments,.segments, anything under.obsidian/(Obsidian's own config folder), and anything not ending in.mdare all rejected with a specific error (e.g.path traversal ('..') is not allowed) rather than silently normalized or, worse, acted on. This closes off using the note tools to read/write/delete arbitrary files on disk or other plugins' config.
Connecting an MCP client
The server listens at http://<bindHost>:<port>/mcp (POST only; GET/DELETE return 405). Example for Claude
Desktop's claude_desktop_config.json (Streamable HTTP over mcp-remote, since Claude Desktop's built-in
config format expects a local command):
{
"mcpServers": {
"obsidian": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"http://127.0.0.1:27123/mcp",
"--header",
"Authorization: Bearer YOUR_TOKEN_HERE"
]
}
}
}
For any MCP client that speaks Streamable HTTP natively, just point it at the URL above with that same
Authorization header — no proxy needed.
Architecture
src/
├── main.ts # Plugin lifecycle: settings load/save, vault event wiring, server start/stop
├── settings.ts # SettingTab UI
├── types.ts # ObsidianMcpSettings + shared result types
├── server/
│ ├── mcpServer.ts # Builds one McpServer per HTTP request, registers all tools/resources/prompts
│ ├── httpTransport.ts # Bare node:http server + StreamableHTTPServerTransport (no Express)
│ ├── auth.ts # Bearer-token check (constant-time compare)
│ └── deps.ts # Shared dependency-injection interface for tools/resources/prompts
├── vault/
│ ├── cache.ts # Event-maintained TF-IDF SearchIndex
│ ├── noteService.ts # CRUD + heading/block/frontmatter patch
│ ├── searchService.ts # keyword / semantic (TF-IDF) / embedding search
│ ├── graphService.ts # backlinks / outgoing links / tag graph
│ ├── batchService.ts # batch read/write
│ └── internalPlugins.ts # Escape hatch for undocumented app.plugins/app.commands/app.internalPlugins
├── github/githubService.ts # GitHub REST client (via Obsidian's requestUrl, no CORS issues)
├── tools/ # One file per tool domain, registerTool() calls
├── resources/noteResources.ts # obsidian://vault/{path} resource template
├── prompts/prompts.ts # daily-note-summary, create-note-from-template, weekly-review
└── shared/toolHelpers.ts # safeHandler() error wrapper, pagination, char-limit truncation
Why a fresh McpServer per HTTP request?
The transport runs in stateless mode (sessionIdGenerator: undefined) per this project's architecture
requirements. That has a real consequence for the resources' list_changed notification: it's wired to
vault events (src/resources/noteResources.ts), but because each POST request gets its own McpServer that's
torn down right after the response, there's no persistent connection for a server-push notification to reach
between requests. The notification code is correct and will fire for a client holding a live connection
during that one request, but most MCP clients reconnect per-turn anyway, so treat "list resources on demand"
as the reliable source of truth rather than relying on push notifications. This tradeoff — stateless HTTP vs.
persistent notification channels — is inherent to the architecture spec this plugin was built against, not an
oversight.
Large content: pagination and truncation
Every tool/resource/prompt that can return a note's raw content caps it, by default, at 25,000 characters
(DEFAULT_CHAR_LIMIT in src/shared/toolHelpers.ts) so one big note can't blow past a client's context window
or balloon latency:
obsidian_read_notetakes optionaloffset(0-based character offset) andlimitparameters to page through a large note manually. Its response includes both a human-readable text marker ([TRUNCATED] {"truncated":true,"totalCharacters":...,"nextOffset":...}) and a structuredstructuredContentobject ({ path, content, totalCharacters, truncated, nextOffset }) — call again withoffset: nextOffsetto get the next page. A call withoutoffset/limitstill works exactly as before, it now just also gets capped and marked if the note happens to be large.obsidian_get_active_noteandobsidian_get_periodic_noteare capped the same way, but with no offset/limit parameters — useobsidian_read_notewith anoffsetto read past the cap.- The
obsidian://vault/{path}resource is capped the same way (resources have no offset/limit parameter in the MCP spec, so a truncated resource read just carries the same text marker). - Prompts (
daily-note-summary,create-note-from-template,weekly-review) cap each note they embed at 8,000 characters, so a prompt combining several large notes doesn't grow unbounded either.
Patch semantics (obsidian_patch_note)
heading: matches a heading's text case-insensitively; the "section" is that heading line through the next heading of equal-or-shallower level (or end of file).block: matches a block reference id (without the leading^), viametadataCache's block index.frontmatter: matches a YAML frontmatter key, viaapp.fileManager.processFrontMatter.
Development
npm run typecheck # tsc --noEmit
npm run build # production bundle -> main.js
npm run dev # esbuild watch mode
npm run verify # runs all four test scripts below in sequence
npm run verify (scripts/run-verify.mjs) runs, in order:
| Script | What it tests | Needs the Obsidian mock? |
|---|---|---|
verify-http.ts |
httpTransport.ts + auth.ts for real: auth (missing/wrong/correct token), method/Host-header rejection, a real EADDRINUSE port conflict and recovery, safe double-close() |
No — real Node http, no obsidian import at all |
verify-notepath.ts |
notePathValidation.ts for real: valid paths pass through, empty/absolute/traversal/.obsidian/non-.md paths are rejected with specific messages |
No — pure function, no obsidian import |
verify-tools.ts |
Full McpServer over a real in-memory MCP Client: tool/resource/prompt registration, an annotation check, obsidian_read_note pagination (30,000-char note, two pages), a resource URI round-trip with #/spaces/Turkish characters, non-.md/traversal rejection through the actual tools, mixed-success batch reads, and direct embedding-response-shape validation (5 malformed payloads + 1 valid one) |
Yes — mocked App (scripts/obsidian-mock.mjs) |
verify-restart.ts |
The real ObsidianMcpPlugin class against a real HTTP port: server reachable after onload(), still reachable after two concurrent restarts, still reachable after an interleaved stop+start+restart, and unreachable (port freed) after onunload() |
Yes — mocked App |
What none of this proves: real Obsidian API behavior (app.vault/app.metadataCache/app.fileManager
against actual files, real TFile/normalizePath semantics), the settings tab UI rendering, or the
undocumented-internal-API tools (obsidian_run_command, obsidian_get_periodic_note,
obsidian_dataview_query) against a real Obsidian instance — none of that can run outside the Obsidian
desktop app itself. Test in a real vault before relying on this in production.
Tool reference
| Tool | Description |
|---|---|
obsidian_read_note |
Read a note's raw markdown by path, with optional offset/limit paging for large notes |
obsidian_write_note |
Create or fully overwrite a note |
obsidian_patch_note |
Surgically edit a heading section, block, or frontmatter key |
obsidian_delete_note |
Permanently delete a note |
obsidian_rename_note |
Rename/move a note, updating internal links |
obsidian_list_notes |
List note paths, optionally filtered by folder prefix |
obsidian_search_notes |
Keyword, local TF-IDF ("semantic"), or optional embedding search |
obsidian_get_backlinks |
Notes that link to a given note |
obsidian_get_outgoing_links |
Notes a given note links to |
obsidian_get_tag_graph |
Every tag in the vault with the notes using it |
obsidian_batch_read |
Read up to 200 notes in one call |
obsidian_batch_write |
Create/overwrite up to 200 notes in one call |
obsidian_get_active_note |
The note currently open in the editor |
obsidian_get_periodic_note |
Today's daily note (daily period only; see caveats above) |
obsidian_run_command |
Execute an Obsidian command by id (internal API, see caveats above) |
obsidian_dataview_query |
Run a DQL query — only registered when Dataview is installed and enabled |
github_get_file |
Fetch a file's decoded content + sha |
github_create_or_update_file |
Commit a new/updated file via the Contents API |
github_list_repo_contents |
List files/folders at a path (one level) |
github_create_issue |
Open a new issue |
github_list_issues |
List issues (PRs excluded) |
github_get_repo_info |
Basic repo metadata |
Plus the obsidian://vault/{path} resource template and the daily-note-summary /
create-note-from-template / weekly-review prompts described above.
License
GNU General Public License v3.0 only (GPL-3.0-only) — see the LICENSE file for the
full text. In short: you can use, modify, and redistribute this plugin, but any distributed derivative work
must also be licensed under GPL-3.0 and its source made available.
Third-party licenses in the bundled main.js: npm run build bundles this project's code together with
its dependencies (@modelcontextprotocol/sdk, zod, and their transitive deps like @hono/node-server) into
one file. Those dependencies are MIT-licensed, which is compatible with GPL-3.0 distribution, but MIT requires
its copyright notice to be preserved in redistributed copies. This repository does not currently ship a
generated third-party-notices file for main.js — if you plan to publish this plugin publicly (e.g. to the
Obsidian community plugin directory), run a license report (e.g. npx license-checker --production) and add a
THIRD-PARTY-NOTICES.md before distributing the built artifact. Flagging this now rather than silently
skipping it.
Before publishing: manifest.json's author/authorUrl fields and package.json's author field are still
empty placeholders — fill those in with your own name/contact before distributing.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。