Skip to main content
Glama
aytacmehmet

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.