mcp-silverbullet
by kesor
README.md
# mcp-silverbullet
Model Context Protocol bridge between [SilverBullet](https://silverbullet.md)
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`](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)` → `bool` — `True` 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)](#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.
## Requirements
- Nix (flake) **or** Python 3.11–3.13 + [uv](https://docs.astral.sh/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.
```bash
T=$(openssl rand -hex 32)
```
2. **SilverBullet** on loopback, same secret if SB auth is on:
```bash
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).
```bash
# 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):
```bash
# 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`):
```bash
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:
```bash
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`](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)](#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):
```bash
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)](#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)](#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
```bash
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.This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues