Skip to main content
Glama
xmatthewx

silverbullet-mcp-server

by xmatthewx
README.md
# silverbullet-mcp-server

Run your own private notes platform that you and Claude can reach, both online and locally on your computer.

[SilverBullet](https://silverbullet.md) is an open-source, self-hosted notes app where every note is a markdown file you control, an open alternative to Notion or Obsidian. Pair it with the
[SilverBullet.plus](https://silverbullet.plus) desktop app and your space syncs to local files, so Claude Code and Cowork can edit them directly. What's still missing is letting Claude on web and mobile reach the same space. **That's the link this server provides:** a remote MCP endpoint with OAuth, so Claude.ai works with your notes from anywhere — collision-safe, so nothing clobbers your edits.

New to all this? You can go from nothing to a private, Claude-connected notes
platform in about 20 minutes. 

→ [QUICKSTART: zero to platform](docs/QUICKSTART.md)

## Intro

`silverbullet-mcp-server` is standalone MCP that exposes a [SilverBullet](https://silverbullet.md)
space to Claude over HTTP — with built-in OAuth 2.1, collision-safe writes, and
structured, model-visible errors.

It runs as its own service and talks to SilverBullet over the HTTP API, so it
works against **any** hosted SilverBullet — including managed hosts like Pikapod
where you can't drop in a sidecar container. (It can equally run as a sidecar next to a self-hosted instance; nothing ties it to one deployment model.) The examples
below deploy to Fly.io, but the server is host-agnostic on both ends.

For now, it's built for a single user at personal note volume. A few scope choices
(stateless JWTs, no refresh token, naive client-side search) are deliberate and
flagged below.

### Highlights

- Standalone remote server — works with managed hosts, not just sidecar setups.
- OAuth 2.1 built in (spec-compliant for the Claude.ai connector), plus a
  static-token bypass for dev/curl.
- Collision-safe overwrites via a `lastModified` version handshake.
- Structured, model-visible errors carrying remediation hints.
- Safe page moves (`move_page`): read→create→delete with a source-unchanged guard; never leaves a caller able to clobber an interim edit.
- Soft-delete, body-size cap, path validation, audit logging.

Status: **v0.6**. See the [CHANGELOG](./CHANGELOG.md) for the full version
history and the design reasoning behind each release.

## Architecture

![Example deployment — Claude connects over OAuth to the MCP server (shown on Fly.io), which reads and writes your SilverBullet space (shown on Pikapod) over its HTTP API with a bearer token.](docs/sb-mcp-system-diagram.svg)

The hosts shown (Fly.io, Pikapod) are just one example — both ends are swappable. The same flow in detail:

```
Claude (web / mobile)
        │
        │  Streamable HTTP, Bearer <JWT>  (OAuth)
        │  -or- Bearer MCP_TOKEN          (dev / curl)
        ▼
[ silverbullet-mcp-server — your host (e.g. Fly.io) ]   <-- this repo
        │   ↑  /authorize, /token, /.well-known/...
        │   │  Owner approves via OWNER_TOKEN in browser
        │
        │  GET /.fs, GET /.fs/<path>, Bearer SB_TOKEN
        ▼
[ Your SilverBullet instance (e.g. Pikapod) ]
   notes.example.com
```

Three credentials, each scoped tightly:

- **`SB_TOKEN`** — this server presents to SilverBullet upstream.
- **OAuth (`OAUTH_CLIENT_ID` / `OAUTH_CLIENT_SECRET` / `OWNER_TOKEN` / `JWT_SIGNING_KEY`)** — primary path for Claude clients. Claude.ai's web UI requires this flow.
- **`MCP_TOKEN`** — static Bearer kept alongside OAuth as a dev/curl bypass so the server stays smoke-testable without running the full auth dance.

Any of them can rotate without touching the others.

## Tools

**Read tools**

| Tool           | Inputs                             | Returns                                              |
| -------------- | ---------------------------------- | ---------------------------------------------------- |
| `list_pages`   | `include_trash?` (bool, def false) | Every `.md` page in the space, sorted by recency. Each entry carries `{page, path, lastModified}`. |
| `read_page`    | `page` (string)                    | Two content blocks: `[0]` JSON envelope `{path, lastModified}`, `[1]` raw markdown body. The `lastModified` is the version marker for a follow-up `write_page`. |
| `search_pages` | `query`, `limit?`, `include_trash?`| Top substring matches with snippets and match counts. |

`include_trash: true` surfaces pages that have been soft-deleted (under `_trash/`).

Search is naive (fan-out fetch + substring). Fine for personal note volumes;
revisit if latency bites.

**Write tools**

| Tool              | Inputs                                        | Behavior                                                         |
| ----------------- | --------------------------------------------- | ---------------------------------------------------------------- |
| `create_page`     | `page`, `body`                                | Creates a new page; errors if it already exists. Returns `{path, lastModified}`. |
| `write_page`      | `page`, `body`, `expected_last_modified`      | Overwrites an existing page. **Collision-safe**: rejected with a conflict error if the server's current `lastModified` differs from `expected_last_modified`. Refuses to create new pages (use `create_page`). Returns `{path, lastModified}`. |
| `append_to_page`  | `page`, `content`                             | Appends content at the end, separated by a blank line. Errors if the page does not exist. No `lastModified` returned (caller has not seen the body and isn't write-ready). |
| `prepend_to_page` | `page`, `content`, `position?`                | Inserts at top or after YAML frontmatter (default). Errors if the page does not exist. Server-side concat — the existing body never passes through the model. No `lastModified` returned. |
| `move_page`       | `from`, `to`                                  | Creates destination (refuses if it exists), re-checks source unchanged, then soft-deletes source. Does **not** rewrite `[[backlinks]]`. Returns `{from, to, moved}` — no `lastModified`. |
| `delete_page`     | `page`                                        | Soft-deletes to `_trash/YYYY-MM/<original-path>`.                |

**Collision-safe overwrites.** `write_page` enforces a version handshake. Workflow: `read_page` returns the current `lastModified` alongside the body; pass that value back as `expected_last_modified` on the subsequent `write_page`. If the page changed in between, the write is rejected with a conflict error and the caller should re-read to reconcile. `create_page` returns a fresh `lastModified` so a caller is left write-ready immediately after creation. `append_to_page` and `prepend_to_page` deliberately omit `lastModified` from their responses — they merge server-side without the caller seeing the full body, so the caller is not in a position to follow up with a guarded `write_page`. There is a narrow TOCTOU window inside `write_page` between the conflict check and the PUT — acceptable for a single-user space; closing it would require an `If-Unmodified-Since` (or equivalent) on the SilverBullet side.

**Version marker hygiene.** The ms-precision `lastModified` is the optimistic-concurrency token for `write_page`. By contract it is only obtainable from `read_page`, `create_page`, or `write_page` — the three tools that have surfaced the full page body. To stop the same value from leaking through other surfaces, `list_pages` and `search_pages` round each entry's `lastModified` to second precision (still useful for recency display, useless as a write key — the rounded value almost never matches the server's true ms value). The `conflict` error response includes `expectedLastModified` (echoing what the caller sent) and a generic "page has been modified" message, but does **not** include the server's current `lastModified` — otherwise a caller could retry the write using the leaked value without re-reading the body. `move_page` suppresses `lastModified` on every path — success and all errors — for the same reason: a leaked source marker would let a caller issue a `write_page` that overwrites edits made to the still-existing source after the move's read step.

**What `move_page` does not do.** It does not rewrite `[[backlinks]]` to the old page name. SilverBullet's `Page: Rename` command handles backlink rewriting, but it's a client-side editor command that requires editor context — not reachable headless over HTTP. If SilverBullet ever exposes Rename as an API call, we'd switch to it.

**Error shape.** Every tool failure comes back as a regular content block carrying a JSON payload with `{error, status, message, ..., remediation}`. The `error` field is a short machine-readable code (`conflict`, `not_found`, `already_exists`, `too_large`, `forbidden_path`, `invalid_path`, `upstream`, `internal`); `status` mirrors the closest HTTP analog (e.g. 409 for `conflict`, 413 for `too_large`); `remediation` gives the agent a concrete next step. The handler stack does **not** set `isError: true` on the MCP response — the Claude.ai connector has been observed to swallow the content payload when that flag is set, leaving the model with only "Error occurred during tool execution." Returning the structured payload as ordinary content keeps the remediation visible. Every error also emits an `[ERROR] tool=... code=... page=...` audit line to stderr (your host's logs).

**Write permission model.** Writes are gated by Claude.ai's per-tool permission UI — set each write tool to Ask for confirmation before every call. No server-side flag or separate OAuth scope; the same connector and credentials serve both read and write tools. On every write the server enforces: a 256 KB body cap, path validation (no `..`, empty segments, double `.md`), and `X-Permission: rw` on every PUT (omitting it would make SilverBullet silently mark the page read-only in its UI). Soft-delete moves pages to `_trash/YYYY-MM/`; collisions in the same month get a `-<unix-ms>` filename suffix. Trash is hidden from list and search by default; `include_trash: true` reveals it. All write operations emit `[WRITE]` audit lines to stderr (visible in your host's logs); `write_page` audit lines include both `expected_last_modified` and the resulting `last_modified`.

## Environment

See `.env.example`. All of the below are required at boot.

| Variable               | What it is                                                                 |
| ---------------------- | -------------------------------------------------------------------------- |
| `SB_URL`               | Base URL of the SilverBullet instance (no trailing slash).                 |
| `SB_TOKEN`             | The `SB_AUTH_TOKEN` configured on the SilverBullet side.                   |
| `MCP_TOKEN`            | Static Bearer accepted as a dev/curl bypass. Generate with `openssl rand`. |
| `PUBLIC_URL`           | Canonical URL of this MCP server. Goes into OAuth metadata documents.      |
| `OAUTH_CLIENT_ID`      | Opaque string. Paste into Claude.ai connector "Advanced settings."         |
| `OAUTH_CLIENT_SECRET`  | Opaque string. Paste into Claude.ai connector "Advanced settings."         |
| `OWNER_TOKEN`          | The password you type into the browser login page to approve a client.    |
| `JWT_SIGNING_KEY`      | Key used to sign 90-day access-token JWTs. Rotating it revokes all tokens.|
| `PORT`                 | HTTP listen port the server binds. Default `8080`; expose it however your host does. |

## Local development

```bash
cp .env.example .env
# fill in every variable; commands to generate the random ones are in the file

npm install
npm run dev
```

Smoke-test against your live SB instance using the dev-bypass token:

```bash
set -a; source .env; set +a

curl http://localhost:8080/healthz                      # 200 always
curl http://localhost:8080/readyz                       # 200 if SB reachable

# OAuth discovery documents (no auth required on these)
curl http://localhost:8080/.well-known/oauth-protected-resource
curl http://localhost:8080/.well-known/oauth-authorization-server

# MCP initialize handshake (uses dev-bypass MCP_TOKEN)
curl -X POST http://localhost:8080/mcp \
  -H "Authorization: Bearer $MCP_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc":"2.0","id":1,"method":"initialize",
    "params":{
      "protocolVersion":"2025-03-26",
      "capabilities":{},
      "clientInfo":{"name":"curl","version":"0"}
    }
  }'
```

## Deploy

The server is a standard Node 20+ HTTP service — run it anywhere that runs Node
(or use the included `Dockerfile`). Wherever it lives, you need to:

- supply the environment variables from the table above as real secrets (never a
  committed `.env`);
- expose the listening `PORT` publicly over HTTPS;
- set `PUBLIC_URL` to the exact public URL clients reach — it's baked into the
  OAuth metadata, so it must match;
- build and start: `npm run build && npm start` (or build the Docker image).

### Example: Fly.io

The repo ships a `fly.toml`, so Fly is the worked example.

First time only:

```bash
fly apps create your-app

# Generate and save your OAuth pair first — you'll paste these into Claude.ai's
# connector settings, and Fly can't show them to you again later.
export OAUTH_CLIENT_ID=$(uuidgen | tr 'A-Z' 'a-z')
export OAUTH_CLIENT_SECRET=$(openssl rand -hex 32)
echo "CLIENT_ID:     $OAUTH_CLIENT_ID"
echo "CLIENT_SECRET: $OAUTH_CLIENT_SECRET"

fly secrets set \
  SB_URL=https://notes.example.com \
  SB_TOKEN=<your SB_AUTH_TOKEN> \
  MCP_TOKEN=$(openssl rand -hex 32) \
  PUBLIC_URL=https://your-app.fly.dev \
  OAUTH_CLIENT_ID=$OAUTH_CLIENT_ID \
  OAUTH_CLIENT_SECRET=$OAUTH_CLIENT_SECRET \
  OWNER_TOKEN=<something memorable but not guessable> \
  JWT_SIGNING_KEY=$(openssl rand -hex 64) \
  --app your-app
```

Then every deploy:

```bash
fly deploy
```

The server scales to zero when idle (see `auto_stop_machines = "suspend"` in
`fly.toml`).

Once you add a custom domain (`fly certs add mcp.example.com`), update
`PUBLIC_URL` to match — OAuth metadata must point at the URL clients actually
reach you on:

```bash
fly secrets set PUBLIC_URL=https://mcp.example.com --app your-app
```

## Connect from Claude (OAuth)

In Claude.ai → **Settings → Connectors → Add custom connector**:

| Field                      | Value                                          |
| -------------------------- | ---------------------------------------------- |
| URL                        | `https://your-app.fly.dev/mcp`                 |
| (Advanced) Client ID       | The `OAUTH_CLIENT_ID` from your secrets        |
| (Advanced) Client Secret   | The `OAUTH_CLIENT_SECRET` from your secrets    |

What happens on first use:

1. Claude calls the MCP and gets a 401 with a `WWW-Authenticate` header.
2. Claude reads the discovery documents and opens your browser to
   `https://your-app.fly.dev/authorize?...`.
3. You see a one-field login page. Enter `OWNER_TOKEN`. Approve.
4. Claude exchanges the resulting code for a 90-day JWT and uses it on every
   subsequent tool call.

When the JWT expires, step 3 repeats. To force re-auth across all clients
immediately, rotate `JWT_SIGNING_KEY` and redeploy.

## Roadmap

- v0.2 — write tools: `create_page`, `write_page`, `append_to_page`, `prepend_to_page`, `delete_page`. Soft-delete, 256 KB cap, audit logging. **Shipped.**
- v0.3 — collision-safe overwrites via `lastModified` envelopes; `read_page` returns `{path, lastModified}` + body; `write_page` requires `expected_last_modified` and is overwrite-only. **Shipped.**
- v0.4 — typed errors (`ConflictError`, `PageNotFoundError`, `FileNotFoundError`, `PageAlreadyExistsError`, `BodyTooLargeError`, `ForbiddenPathError`, `InvalidPathError`, `UpstreamError`) routed through a central `mapToolError` that returns structured `{error, status, message, remediation}` payloads as regular content (no `isError`), plus `[ERROR]` audit lines. **Shipped.**
- v0.5 — version-marker hygiene: `lastModified` no longer leaks through `list_pages`, `search_pages`, or conflict-error payloads; rounded to second precision in recency contexts. **Shipped.**
- v0.6 — `move_page` (safe page move); `append_to_page` / `prepend_to_page` no longer silently create pages. **Shipped.**

Planned, not yet scheduled:

- Cached file index, real search ranking, frontmatter awareness.
- Refresh tokens, so JWT renewal is silent.
- Path-prefix allowlist (restrict writes to specific directories if the broad write surface ever feels too open).
- Atomic writes (current SB backend uses non-atomic `os.WriteFile`).
- Switch `statFile` to a header-based metadata GET once SB's `Last-Modified` header behavior is verified, to avoid the per-write directory listing.

The [CHANGELOG](./CHANGELOG.md) doubles as a design log — the reasoning behind
each release, not just the diff.

## Maintenance

Provided as-is. This is a personal-scale project, shared in case it's useful to
others; expect light, best-effort maintenance. Issues and pull requests are
welcome — especially against the roadmap items above — but response times will
vary.

## License

[MIT](./LICENSE) © Beta Brooklyn.