Skip to main content
Glama
kesor

mcp-silverbullet

by kesor

mcp-silverbullet

Model Context Protocol bridge between SilverBullet and MCP clients (Grok Custom Connectors, mcp CLI, …). The bridge is a side-car on loopback; it does not provision tunnels.

Architecture and threat model: docs/design.md.

What it exposes

Fourteen tools + one resource template. Every write tool returns the new etag on every successful write — read it and feed it back via if_match on the next call to fail fast on stale etags.

  • read_page(name){body, etag, size_bytes, last_modified_ms} — markdown body and metadata. etag is None if SB stripped the ETag response header.

  • page_exists(name)boolTrue on 200, False on 404, ToolError on 5xx so "no, proceed" stays distinct from "SB is broken".

  • write_page(name, content, if_match?){name, etag, size_bytes, last_modified_ms, created_ms} — create or overwrite. Empty name/content raises ToolError("... must not be empty") upfront (T40).

  • create_page(name, content) → same envelope as write_page — but refuses to overwrite an existing page, surfacing ToolError("page already exists: {name}; use write_page to overwrite") on collision. if_match="*" is implied; use write_page directly if you want to write with a precondition.

  • append_to_page(name, text, if_match?, dry_run=False) — read-modify- write append (one newline separator inserted unless the body already ends in one); returns the same envelope. With dry_run=True returns {dry_run, original, patched, diff} without writing. Empty text raises ToolError("text must not be empty") upfront (T40).

  • prepend_to_page(name, content, position="after_frontmatter"|"top", if_match?, dry_run=False) — top-of-body insert with YAML frontmatter awareness. Default position="after_frontmatter" inserts the new content between the closing --- of the frontmatter block and the first body line (the human-meaningful default for journal / daily-notes pages); position="top" overrides and inserts above the frontmatter. Both positions produce the same splice on pages without frontmatter. Malformed frontmatter (opening fence but no close) is treated as no-frontmatter.

  • patch_page_lines(name, start_line, end_line, new_content, if_match?, dry_run=False) — replace lines start_line..end_line (1-indexed, inclusive) with new_content; pass new_content="" to delete a range; preserves the page's trailing newline if it had one. Empty name raises ToolError("name must not be empty") upfront (T40).

  • patch_page_replace(name, find, new_string, replace_all=False, if_match?, dry_run=False) — literal substring replace (no regex); replace_all=False (the safe default) errors if find matches more than once, so a typo never silently mass-edits.

  • move_page(name, new_name, if_match?) — rename (write-then-delete so a partial failure leaves the body at the new name); destination always refuses to overwrite. Empty name/new_name raises ToolError("name must not be empty") upfront (T40).

  • delete_page(name, if_match?){name, etag, size_bytes=None, last_modified_ms=None, created_ms=None} — hard delete; SB's DELETE response doesn't echo timestamps / size. Empty name raises ToolError("name must not be empty") upfront (T40).

  • list_pages(prefix?, contains?)[{name, etag, size_bytes, last_modified_ms, created_ms}][] — sends X-Sync-Mode: 1 so SB 2.x returns JSON from GET /.fs instead of 307-redirecting to the SPA. prefix= does startswith matching (unchanged from v1); contains= (T37) does substring matching against the page name. Both filters compose as AND when both are set; either empty is a no-op for that criterion; both empty returns the full listing. Both filters run client-side before per-page hydration, so a narrow filter reduces the N+1 round-trip count. The etag field is null on this SB build unless you opt in to per-page hydration with MCP_SILVERBULLET_LIST_PAGES_HYDRATE_ETAGS=1 (one GET per row; partial failures leave the affected row's etag as null rather than failing the whole call). The filter only ever matches against page names; body-content search lives behind the journal gate (see "Discovery tools (journal-gated)" below).

  • diff_pages(name, other_name?, other_body?){diff, name, other?} — line-based unified diff between two pages (or a page and a literal string). Pass exactly one of other_name / other_body; passing neither or both is ToolError("pass exactly one of other_name or other_body") upfront, no wasted read.

  • list_tasks(page?, prefix?)[{name, ref, line, state, text}][] — enumerate checkbox bullets on a page (per-page form, always available via GET /.fs/{page}) or across the whole space (space-walk form, gated — see Discovery tools (journal-gated)). state is the literal checkbox character (" " for [ ], "x" for [x], "X" for [X]). Frontmatter-block bullets are skipped.

  • check_task(page, ref, state="done", if_match?, dry_run=False) — flip a checkbox bullet's state by its wikilink ref. Reads the page, finds the unique bullet whose wikilink target equals ref, flips the marker, writes the body back via PUT /.fs/{page} with If-Match: <read_etag> so a concurrent edit fails rather than silently clobbering. state="done" flips to [x], "todo" to [ ], "cancelled" to [X].

  • silverbullet://page/{name} → JSON envelope {body, etag, size_bytes, last_modified_ms} (same shape as read_page; MIME type application/json).

Concurrency: read-then-write with current etag

Every write tool accepts if_match ("*" to require existence, <etag> to require an exact body match, None for unconditional). On this build, SilverBullet does not honor If-Match and does not return ETag on PUT — the bridge synthesizes a fallback etag from X-Content-Length (T44; pre-T44 the form was "{last_modified_ms}-{size_bytes}") and runs a post-write re-read to compare. If a stale etag slips through, the bridge raises ToolError("concurrent edit detected: …; read it again and re-issue the write with the current etag"). The fix is always the same: read the page again, take the new etag from the response, retry the write.

On a 412, the bridge tells you the next call's exact if_match= value (silent-overwrite path) or the read_page(<name>) call that gives you that value (standard path). You don't need to guess. The silent-overwrite path (the W36 pattern: SB ignored If-Match and wrote anyway, the bridge's post-write re-read caught the drift) embeds the post-write etag in the error message — the agent's next call is write_page(name, content, if_match="<that etag>"), no extra round trip. The standard path (SB honored If-Match and returned 412) embeds a literal read_page("<name>") token pointing at the page; SB's 412 response body is empty on this build so the bridge can't surface the etag directly, but the next call is unambiguous. Both surfaces are byte-additive over v1.5 — an agent that pattern-matches on the bare precondition failed or concurrent edit detected prefix still matches; only agents that pinned the byte-for-byte full message need to update. The T42 [concurrent_edit_hint: true] contention marker still rides as a trailing suffix after the v1.6 wording change.

T46 silent-overwrite 412 is rare on read-modify-write tools. v1.6 narrowed the post-write verification helper's detection semantic: it now compares the verification-GET etag against the PUT-response etag (the bridge's view of "what we just wrote"), not the caller's pre-write if_match. The pre-v1.6 helper raised a spurious "concurrent edit detected" on every read-modify-write that grew the page (the synthesized etag is str(size_bytes) per T44; the post-write size differs from the pre-write size on every append / prepend / patch / move). Live reproduction on this dev box confirmed: 76 spurious errors in 6 hours on Trading Book/ Logs/2026-W36.md, every one with current_etag - expected_etag exactly equal to the appended content length. T46 closes that gap — read-modify-write tools now succeed on a non-race write. The helper still catches genuine concurrent edits that land between the bridge's PUT and the verification GET (the narrow window in which another writer can land a PUT on the same page). A caller-supplied stale if_match (the agent manages its own etag round-trip and the page drifted in the gap) no longer fires the helper; that defense moved to the agent side — re-read on the agent's own retry loop, or use the bridge's read-modify-write tools (which do their own internal re-read and pass the verification check).

T47 auto-retry is the default on read-modify-write tools. By default, append_to_page / prepend_to_page / patch_page_lines / patch_page_replace / check_task / move_page retry up to max_retries=3 times when the post-write verification helper fires concurrent edit detected. On each retry the bridge re-reads the body, re-derives the operation against the page's current state, and re-PUTs. Pass max_retries=0 to opt out and see the raw 412 (matches pre-v1.6 behavior). Genuine semantic errors (find not found in body, page not found, body-size errors) surface to the agent unchanged — the bridge retries only on the post-write-verification race, not on anchor-mismatch or 404. The standard-412 path (SB honored If-Match and returned 412) is not auto-retried: an agent that passed an explicit stale if_match should see the 412 — retrying would mask the precondition failure.

Every successful write returns the new etag. Pass it to the next if_match on the same page and you'll never see the concurrency error.

Dry-run mode

append_to_page / prepend_to_page / patch_page_lines / patch_page_replace / check_task accept dry_run=True to preview a patch without committing. The read still happens (the tool needs the body to compute the patch), if_match is validated against the read's etag, and the response is {dry_run, original, patched, diff}. A no-op patch returns an empty diff.

Discovery tools (journal-gated)

When list_pages(prefix=, contains=) narrows the listing but the page you want isn't on a name match — its name doesn't contain the phrase, but its body does — the HTTP /.fs API can't help: SB has no built-in search endpoint, so substring search over page bodies needs filesystem access to the SB space directory. Three tools provide that; they live behind the journal gate, enabled by setting both:

  • MCP_SILVERBULLET_SPACE_PATH — absolute path to the SB space directory (typical: same host that runs SilverBullet; rare behind a containerized split).

  • MCP_SILVERBULLET_JOURNAL_TOOLS=1 — truthy opt-in flag (1 / true / yes / on).

Without either, the bridge boots cleanly without these tools and logs a single INFO/WARN line. Restart the bridge after changing either env var.

  • pages_touching_topic(query, prefix?) — case-insensitive name+content substring search; returns {name, match, snippet}[] (match is "name", "content", or "both"). Uses rg --json when available, falls back to pure-Python otherwise.

  • search_pages(query, prefix?, limit=20) — bounded variant of pages_touching_topic with a limit knob (default 20, hard cap 100). Same wire shape. Use this when you want the top N hits; pages_touching_topic for unbounded scans.

  • find_backlinks(target) -> [{file, line, text}] — wikilink-target backlinks for the rename-pre-flight workflow. file is the relative path to the linking page, line is the 1-indexed editor line number, text is the stripped line. Target normalization: leading/trailing slashes and a trailing .md are stripped; aliases ([[target|alias]]) match the bare target. Self-links are returned (filter client-side). Empty / whitespace-only target raises ToolError("target must not be empty") upfront.

Three additional journal tools (also gated, but not discovery-flavoured):

  • journal_histogram(prefix?) — bucket *.md pages by YYYY-MM.

  • tag_summary(prefix?) — count occurrences of every tags: value.

  • recent_pages(limit?, prefix?) — newest pages by mtime.

Related MCP server: silverbullet-mcp-server

Requirements

  • Nix (flake) or Python 3.11–3.13 + uv

  • A running SilverBullet (/.fs HTTP API)

  • Optional: an existing Cloudflare tunnel (or nginx) in front of 127.0.0.1:8000

Boot order

  1. Generate a token (any high-entropy string). This is T below.

    T=$(openssl rand -hex 32)
  2. SilverBullet on loopback, same secret if SB auth is on:

    SB_AUTH_TOKEN=$T silverbullet --hostname 127.0.0.1 --port 3000 /path/to/space

    If your SilverBullet has no auth (dev box), leave SB without a token and set MCP_SILVERBULLET_SB_TOKEN empty on the bridge (step 3).

  3. Bridge — from a checkout. The bridge defaults to JWT mode (validates tokens against an IdP's JWKS); set MCP_SILVERBULLET_AUTH_MODE=static for the legacy shared-secret surface (used by mcp dev and other non-IdP setups).

    # JWT mode (default — bridge sits behind Cloudflare Access,
    # Auth0, Okta, Google-IAP, …; validates per-user tokens against
    # the IdP's JWKS):
    export MCP_SILVERBULLET_JWT_ISSUER=https://<org>.cloudflareaccess.com
    export MCP_SILVERBULLET_JWT_AUDIENCE=<AUD-tag-from-CF-dashboard>
    export MCP_SILVERBULLET_JWT_JWKS_URL=https://<org>.cloudflareaccess.com/cdn-cgi/access/certs
    export MCP_SILVERBULLET_SB_URL=http://127.0.0.1:3000
    nix run .#mcp-silverbullet
    
    # Static mode (legacy shared-secret):
    export MCP_SILVERBULLET_AUTH_MODE=static
    export MCP_SILVERBULLET_TOKEN=$T
    export MCP_SILVERBULLET_SB_URL=http://127.0.0.1:3000
    nix run .#mcp-silverbullet

    Common knobs (both modes):

    # optional: empty when SB has no auth
    # export MCP_SILVERBULLET_SB_TOKEN=
    # optional: public URL stamped into WWW-Authenticate + discovery
    # export MCP_SILVERBULLET_RESOURCE_URL=https://<tunnel>/mcp
    # optional: extra Host values when nginx/cloudflared forward a public name
    # export MCP_SILVERBULLET_ALLOWED_HOSTS=<mcp>.local,<tunnel>.trycloudflare.com

    Equivalent without Nix: uv sync && uv run mcp-silverbullet. Listens on http://127.0.0.1:8000/mcp by default (MCP_SILVERBULLET_HOST / MCP_SILVERBULLET_PORT).

  4. Tunnel (operator-owned; this repo does not start cloudflared):

    cloudflared tunnel --url http://127.0.0.1:8000
  5. Client — in JWT mode, paste https://<tunnel>/mcp and let the IdP handle auth (the bridge trusts whatever IdP-issued JWT reaches it). In static mode, paste the bearer too:

    MCP_SILVERBULLET_AUTH_MODE=static MCP_SILVERBULLET_TOKEN=$T \
      mcp dev http://127.0.0.1:8000/mcp

    If a quick-tunnel URL rotates, the bearer stays; re-paste the new URL.

    For a full Cloudflare Access + Managed OAuth + cloudflared setup (named tunnel, public hostname, per-user JWT validation against the Cloudflare team JWKS), see docs/cloudflare-setup.md. It covers the nginx config that copies Cf-Access-Jwt-Assertion into Authorization: Bearer, rewrites the Host header so the bridge's MCP transport-security check passes, and the Access app config (Managed OAuth, DCR redirect-URI allowlist, bypass for the discovery endpoint).

Use from a Pi coding agent session

The repo ships with a project-local .mcp.json so a Pi session running in this checkout discovers the bridge automatically (via the pi-mcp-adapter extension). After python -m mcp_silverbullet (or nix run .#mcp-silverbullet) is running on 127.0.0.1:8000, run /reload in Pi and the bridge's fourteen always-on tools register as direct Pi tools. The journal-surface tools register additionally when MCP_SILVERBULLET_JOURNAL_TOOLS=1 and MCP_SILVERBULLET_SPACE_PATH are both set (see Discovery tools (journal-gated)).

In static mode, the bearer token is read at HTTP-connect time via the !command syntax in .mcp.json, pointed at ~/.config/mcp-silverbullet/token (mode 600):

python -c 'import secrets; print(secrets.token_hex(32))' \
  > ~/.config/mcp-silverbullet/token
chmod 600 ~/.config/mcp-silverbullet/token

The bridge is a side-car, not a daemon: it has to be running for the tools to work, and lifecycle: lazy in .mcp.json means Pi won't try to connect until the first tool call.

Env vars

Variable

Default

Role

MCP_SILVERBULLET_AUTH_MODE

jwt

jwt (default) validates per-user tokens against the IdP's JWKS via MCP_SILVERBULLET_JWT_*. static accepts a single shared secret via MCP_SILVERBULLET_TOKEN (legacy surface).

MCP_SILVERBULLET_TOKEN

(required when AUTH_MODE=static; ignored otherwise)

Shared bearer secret. Compared constant-time against the inbound Authorization: Bearer … header.

MCP_SILVERBULLET_JWT_ISSUER

(required when AUTH_MODE=jwt)

Expected iss claim. Cloudflare Access: https://<org>.cloudflareaccess.com.

MCP_SILVERBULLET_JWT_AUDIENCE

(required when AUTH_MODE=jwt)

Expected aud claim. Cloudflare Access: the per-application AUD tag from the Zero Trust dashboard.

MCP_SILVERBULLET_JWT_JWKS_URL

(required when AUTH_MODE=jwt)

JWKS endpoint URL. Cloudflare Access: https://<org>.cloudflareaccess.com/cdn-cgi/access/certs.

MCP_SILVERBULLET_JWT_ALGORITHMS

RS256

Comma-separated JWA algorithm allow-list. Pinned to RS256 by default so the bridge refuses the classic algorithm-confusion attack (HS256-signed with the public key as secret). Operators on Auth0/Okta/Google-IAP pass the IdP's algorithm.

MCP_SILVERBULLET_JWT_LEEWAY_SECONDS

30

Clock-skew tolerance for exp / iat / nbf. CF Access's recommended value.

MCP_SILVERBULLET_SB_URL

http://127.0.0.1:3000

SilverBullet origin.

MCP_SILVERBULLET_SB_TOKEN

same as MCP_SILVERBULLET_TOKEN

Outbound SB bearer; empty string = no header.

MCP_SILVERBULLET_RESOURCE_URL

http://127.0.0.1:8000/mcp

Discovery + WWW-Authenticate.

MCP_SILVERBULLET_HOST

127.0.0.1

Bind address.

MCP_SILVERBULLET_PORT

8000

Bind port.

MCP_SILVERBULLET_ALLOWED_HOSTS

(unset → SDK loopback default)

Extra Host values, comma-separated.

MCP_SILVERBULLET_SPACE_PATH

(unset)

Absolute path to the SB space directory; required to enable the journal surface (see Discovery tools (journal-gated)).

MCP_SILVERBULLET_JOURNAL_TOOLS

(unset)

Truthy (1 / true / yes / on) enables the six journal tools; requires MCP_SILVERBULLET_SPACE_PATH to be set and readable (see Discovery tools (journal-gated)).

MCP_SILVERBULLET_LIST_PAGES_HYDRATE_ETAGS

(unset)

Truthy enables per-page etag-hydration on list_pages. Default off (N+1 cost is opt-in). The SB list payload omits the etag field on this build; an operator who needs if_match round-trips from a list call pays one GET per row to hydrate.

MCP_SILVERBULLET_LOG_LEVEL

INFO

DEBUG / INFO / WARNING / ERROR / CRITICAL. DEBUG dumps sanitized HTTP request metadata and classifies uvicorn's opaque Invalid HTTP request received warnings (HTTP/2 preface vs TLS ClientHello vs junk). UVICORN_LOG_LEVEL is ignored — uvicorn is configured from this var.

MCP_SILVERBULLET_DEBUG

(unset)

Truthy (1 / true / yes / on) is a one-knob alias for LOG_LEVEL=debug. Explicit LOG_LEVEL wins if both are set.

WARNING: Invalid HTTP request received. from uvicorn means the first bytes on the socket were not HTTP/1. This uvicorn build does not log what they were, even at debug. Set MCP_SILVERBULLET_LOG_LEVEL=debug (or MCP_SILVERBULLET_DEBUG=1) and the bridge logs a classification + the first 200 bytes next to that warning. Typical causes behind Cloudflare Access / cloudflared: HTTP/2 to an HTTP/1 origin (http2-preface), HTTPS hitting the HTTP port (tls-clienthello), or a TCP health check with no HTTP (empty / unknown).

Dev

nix develop          # editable source + pytest
pytest               # Layer 1–2, no live SB
nix flake check

MCP SDK is pinned at mcp==2.1.1 (uv.lock). License: MIT.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.

No tool schema history has been recorded yet.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/kesor/silverbullet-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server