Obsidian MCP Server
by aytacmehmet
README.md
# Obsidian MCP Server
An Obsidian plugin that runs an embedded [Model Context Protocol](https://modelcontextprotocol.io) 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](https://github.com/coddingtonbear/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 in `scripts/obsidian-mock.mjs` only stubs 2-3 methods well enough to smoke-test tool registration, it is not a faithful vault
- `obsidian_run_command` and `obsidian_get_periodic_note`, which both use **undocumented internal Obsidian APIs** (`app.commands`, `app.internalPlugins`) not present in the public `obsidian.d.ts` at 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 reads `app.plugins.plugins.dataview.api` via 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_write` for 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 `resources` template (`obsidian://vault/{path}`), and 3 `prompts` — 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's `node: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)
```bash
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:
```bash
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.1` by 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.ts`
rejects any request whose `Host` header isn't `127.0.0.1`, `localhost`, or the configured bind host — unless
the bind host is `0.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 other `github_*` write tools are all marked `destructiveHint: true` in 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 `.md` paths.** 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 `.md` are
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):
```json
{
"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_note`** takes optional `offset` (0-based character offset) and `limit` parameters to page
through a large note manually. Its response includes both a human-readable text marker
(`[TRUNCATED] {"truncated":true,"totalCharacters":...,"nextOffset":...}`) and a structured
`structuredContent` object (`{ path, content, totalCharacters, truncated, nextOffset }`) — call again with
`offset: nextOffset` to get the next page. A call without `offset`/`limit` still works exactly as before, it
now just also gets capped and marked if the note happens to be large.
- **`obsidian_get_active_note`** and **`obsidian_get_periodic_note`** are capped the same way, but with no
offset/limit parameters — use `obsidian_read_note` with an `offset` to 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 `^`), via `metadataCache`'s block index.
- `frontmatter`: matches a YAML frontmatter key, via `app.fileManager.processFrontMatter`.
## Development
```bash
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](LICENSE) only (`GPL-3.0-only`) — see the [`LICENSE`](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.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues