silverbullet-mcp-server

silverbullet-mcp-server

Exposes a SilverBullet note space to Claude via MCP, with OAuth 2.1, collision-safe writes, and structured errors.

Category
Visit Server

README

silverbullet-mcp-server

A standalone MCP server that exposes a SilverBullet 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.

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 — they double as the contribution roadmap.

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.
  • Soft-delete, body-size cap, path validation, audit logging.

Status: v0.5. See the CHANGELOG 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.

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. 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). Server-side concat — the existing body never passes through the model. No lastModified returned.
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.

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

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:

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:

fly apps create your-app

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=$(uuidgen | tr 'A-Z' 'a-z') \
  OAUTH_CLIENT_SECRET=$(openssl rand -hex 32) \
  OWNER_TOKEN=<something memorable but not guessable> \
  JWT_SIGNING_KEY=$(openssl rand -hex 64) \
  --app your-app

Then every deploy:

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:

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 — cached file index, real search ranking, frontmatter awareness.
  • v0.7 — refresh tokens, so JWT renewal is silent.
  • future — 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 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 © Beta Brooklyn.

Recommended Servers

playwright-mcp

playwright-mcp

A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.

Official
Featured
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

graphlit-mcp-server

The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.

Official
Featured
TypeScript
Kagi MCP Server

Kagi MCP Server

An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.

Official
Featured
Python
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
Exa Search

Exa Search

A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured