Skip to main content
Glama
DanielChahine0

obsidian-mcp-server

README.md
# obsidian-mcp-server

A remote MCP server that exposes an Obsidian vault stored in a GitHub repo as callable tools, so Claude can search and read your notes on demand, including on unattended scheduled runs like the morning brief.

Your laptop does not need to be on. The server reads from GitHub, not from your machine.

## Why this instead of the GitHub Integration

The GitHub Integration in Claude lets you attach repo files by hand. That is a manual click, so a scheduled task cannot use it. This is a real MCP connector: Claude gets tools it can call by itself.

It also understands Obsidian rather than treating notes as code. It parses YAML frontmatter, block-style and inline tag lists, inline `#tags` (ignoring `#` inside code fences), `[[wikilinks]]`, and daily-note dates from filenames.

## Tools

| Tool | Purpose |
|---|---|
| `obsidian_search_notes` | Full-text search, ranked. Title matches beat tag matches beat body frequency. All terms must be present. |
| `obsidian_get_note` | Read one note by path, path without extension, or bare title. |
| `obsidian_list_notes` | Browse by folder, tag, or date range. This is the daily-notes tool. |
| `obsidian_list_tags` | Every tag with note counts, so you can discover the vocabulary before filtering. |
| `obsidian_vault_status` | Vault size, folder breakdown, snapshot freshness. `refresh=true` forces a refetch. |

## How it reads the vault

It downloads the repo tarball in one API call and builds an in-memory index, rather than making one call per note. A vault with thousands of notes costs a single request. The snapshot is cached for `CACHE_TTL_MS` (default 5 minutes), and concurrent tool calls share one refresh instead of triggering several downloads.

## Setup

### 1. Vault to GitHub

Install the Obsidian Git community plugin and point it at a **private** repo. Set auto-commit to something like 10 minutes. Everything downstream reads from that repo.

### 2. Fine-grained GitHub token

Create one at Settings → Developer settings → Personal access tokens → Fine-grained tokens.

- Repository access: **only** the vault repo
- Permissions: **Contents → Read-only**

Nothing else. Whatever this token can reach, the server can reach.

### 3. Shared secret

```bash
openssl rand -hex 32
```

This is `MCP_AUTH_TOKEN`. It is the only thing between a stranger who finds your URL and your entire vault.

### 4. Environment

| Variable | Required | Default | Notes |
|---|---|---|---|
| `GITHUB_OWNER` | yes | — | Your GitHub username |
| `GITHUB_REPO` | yes | — | Vault repo name |
| `GITHUB_TOKEN` | yes | — | The fine-grained token |
| `MCP_AUTH_TOKEN` | yes | — | The shared secret |
| `GITHUB_REF` | no | `main` | Branch to read |
| `VAULT_SUBPATH` | no | `""` | Index only a subfolder |
| `CACHE_TTL_MS` | no | `300000` | Snapshot lifetime |
| `PORT` | no | `3000` | Host usually sets this |

### 5. Run locally

```bash
npm install
npm run build
GITHUB_OWNER=you GITHUB_REPO=vault GITHUB_TOKEN=ghp_... MCP_AUTH_TOKEN=secret npm start
```

Verify with the MCP Inspector:

```bash
npx @modelcontextprotocol/inspector
```

Connect to `http://localhost:3000/mcp` over Streamable HTTP with header `Authorization: Bearer <MCP_AUTH_TOKEN>`.

There is also an end-to-end test that stubs GitHub with a synthetic vault and drives the real HTTP endpoint:

```bash
node test-e2e.mjs
```

## Endpoints

| Path | Auth | Purpose |
|---|---|---|
| `POST /mcp` | required | The MCP endpoint. This is the URL you give Claude. |
| `GET /health` | none | Liveness probe. Does no index work, so a platform health check can never trigger a GitHub fetch. Reports cache warmth and note count. |
| `GET /warm` | required | Rebuilds the index if it has expired. Point your uptime pinger here. Add `?refresh=true` to force a rebuild. |

## Staying warm

The index is built on startup, right after the port binds rather than before it, so deploys are not delayed by a vault download and a large vault cannot fail the platform's health check.

That handles restarts. The other half is idle shutdown. On any tier that sleeps, a once-a-day scheduled run always arrives at a cold server and pays twice: once for the container to boot, once for the index to rebuild. If the total exceeds the connector timeout, the tool call fails, and a brief section that returns nothing is dropped silently rather than showing an error.

To avoid that, point a free pinger (cron-job.org, UptimeRobot) at `/warm` every 10 minutes:

```
https://your-service.onrender.com/warm
Header: Authorization: Bearer <MCP_AUTH_TOKEN>
```

If your pinger cannot send custom headers on its free plan, the token is also accepted as a query parameter:

```
https://your-service.onrender.com/warm?token=<MCP_AUTH_TOKEN>
```

Prefer the header. URLs routinely end up in access logs; headers usually do not.

Ping `/warm` rather than `/health`, because `/health` keeps the container alive but leaves the index cold, which only fixes half the problem.

Raise `CACHE_TTL_MS` well above the 5 minute default once pinging is in place. The default is tuned for interactive use, where you want recent edits to show up quickly. For a scheduled brief, something like 6 hours (`21600000`) means far fewer rebuilds while still picking up the day's notes.

If your brief runs at a fixed time, schedule an extra ping 10 to 15 minutes before it, so both the container and the index are hot when it fires.

## Deploy

Any host that gives you an HTTPS URL works. Anthropic connects **from its own cloud**, not from your machine, so the server must be reachable on the public internet. A VPN-only or firewalled host will not connect even if you can reach it yourself.

Set the four required env vars as secrets, deploy, confirm `GET /health` returns `{"status":"ok"}`.

Notes on hosting choices:

- **Render / Railway / Fly** all work with this code unchanged. Check current free-tier terms before relying on one.
- **Cold starts matter here.** See "Staying warm" above; on a sleeping tier this is the single thing most likely to break a scheduled run.

## Add to Claude

Settings → Connectors → Add custom connector.

- URL: `https://your-host.example.com/mcp`
- Under Advanced settings, add request header `Authorization: Bearer <MCP_AUTH_TOKEN>`

If your account does not yet show a request-headers field, that capability is still rolling out. Until it does, treat the URL itself as a secret and deploy on an unguessable path.

Then enable the connector in a conversation via the `+` menu. A connector enabled mid-conversation generally will not appear until you start a new chat.

## Using it in the morning brief

The brief accepts a `Sections:` list with the invocation and makes one targeted fetch per entry against whatever connected tool serves it. Once this connector is live, a section like:

```
Sections: Open TODOs from my daily notes this week
```

will route to `obsidian_list_notes` with `folder="Daily"` and a `since` date. A section that finds nothing is dropped from the page rather than rendering an empty block.

## Security

- The GitHub token is read-only and scoped to one repo, so a compromise cannot write to your vault.
- The bearer check is constant-time, so it does not leak the token's length or prefix through timing.
- Every tool is read-only and annotated as such.
- There are no write tools. Claude cannot modify your notes through this server.

## Troubleshooting

| Symptom | Cause |
|---|---|
| Connector adds but shows no tools | Transport mismatch. This server speaks Streamable HTTP at `POST /mcp`. |
| 401 on every call | Header missing or malformed. It must be `Authorization: Bearer <token>`. |
| "Repository or ref not found" | Wrong owner/repo/ref, or the token cannot see a private repo. |
| "GitHub denied access" | Rate limit, or the token is missing Contents:Read. |
| Notes missing from results | Check `VAULT_SUBPATH`, and note that `.obsidian/`, `.trash/`, `.git/` are skipped by design. |
| Stale results after syncing | Snapshot cache. Call `obsidian_vault_status` with `refresh=true`, or lower `CACHE_TTL_MS`. |
| First call each morning fails | Host cold start. Ping `/warm` on a schedule; see "Staying warm". |
| `/warm` returns 401 | Token missing. Use the `Authorization` header, or `?token=` if your pinger cannot send headers. |
| `/health` shows `"warm": false` | Index not built yet. Normal for a few seconds after a deploy; persistent means the startup warm failed, so check the logs for the reason. |