mcp-silverbullet
by jgurhem
README.md
# mcp-silverbullet
An MCP server that exposes a remote [SilverBullet](https://silverbullet.md) space
to an MCP client, over SilverBullet's `/.fs` HTTP API.
Reads are unrestricted across the space; writes are confined to a configurable
page prefix.
## Requirements
- Python 3.11+ (`tomllib`)
- A SilverBullet instance and an API token (admin UI > Users > API tokens)
## Install
```sh
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
```
## Configuration
Settings live in a TOML file, `config.toml` by default:
```sh
cp config.toml.example config.toml
```
| Key | Required | Default | Description |
| ---------------- | -------- | ------------ | ------------------------------------------------------------ |
| `base_url` | yes | — | Space URL including its prefix, e.g. `https://notes.example.fr/work` |
| `token` | yes | — | API token used for every request |
| `write_prefix` | no | `Inbox/` | Page prefix under which writing is allowed |
| `hide_prefixes` | no | `["Library/"]` | Page prefixes hidden from listing and search |
| `host` | no | `127.0.0.1` | Interface the HTTP transport binds to |
| `port` | no | `8000` | Port the HTTP transport listens on |
The file holds a token, so it is gitignored. An unknown key is an error rather
than a silent fallback to the default.
## Run
```sh
.venv/bin/python sb_mcp_http.py # reads ./config.toml
.venv/bin/python sb_mcp_http.py other.toml # or a path of your own
```
The server speaks MCP over the `streamable-http` transport, on `/mcp`. It binds
to `127.0.0.1` by default, so a client on the same machine reaches it at
`http://127.0.0.1:8000/mcp`.
To reach it from anywhere else, put a reverse proxy in front of it rather than
changing `host` — see [Deployment](#deployment).
## Tools
| Tool | Description |
| ---------------- | ------------------------------------------------------------------ |
| `list_pages` | Pages in the space, most recently modified first, minus `hide_prefixes` |
| `read_page` | Markdown body of one page, named without the `.md` extension |
| `search_pages` | Case-insensitive search over page names and bodies |
| `create_note` | Create a page, failing if it already exists |
| `append_to_note` | Append text to an existing page without overwriting the rest |
| `replace_note` | Replace a page's whole content, to correct a note already written |
| `delete_note` | Delete a page |
Every write tool rejects any page outside `write_prefix`, and they use HTTP
conditional requests — `If-None-Match` on create, `If-Match` on append,
replace and delete — so a page that changed underneath the server is neither
clobbered nor erased. A space that sends no `ETag` gets no write at all.
## Filing notes outside the write prefix
Writes are confined to `write_prefix`, but most notes belong somewhere else —
a journal page, a project page. `create_note` and `replace_note` take a
`destination` for that: the note is still written under the prefix, with a
frontmatter key and a **File** button that files it on one click.
```
create_note(name="Inbox/client-sync", content="2:30pm client sync, nothing to report",
destination="Journal/2026-09-10")
```
produces
```markdown
---
destination: Journal/2026-09-10
---
${inbox.button()}
2:30pm client sync, nothing to report
```
Clicking **File** appends the body to `Journal/2026-09-10` and deletes the
note; the frontmatter and the button line are stripped on the way. The
destination is re-read after the write, and the source is deleted only once the
body is confirmed there — a write that did not land never costs the note.
The client never writes that frontmatter itself: it passes `destination`, the
server renders it, and the server's MCP instructions say so. Omit `destination`
and the content is written verbatim, for a note that genuinely lives under the
prefix.
The button and the command are not part of this server — they live in the
SilverBullet space, as [`space-lua/Inbox.md`](space-lua/Inbox.md), a SilverBullet
library. Install it with the `Library: Install` command and this URI:
```
https://github.com/jgurhem/MCP-Silverbullet/blob/main/space-lua/Inbox.md
```
It lands at `Library/jgurhem/Inbox`, and `Library: Update` pulls later versions —
no copying by hand. Reading a public repo needs no GitHub token.
Installing overwrites that page, so the pending-notes list lives in a page of
your own — your `index`, a `Meta/Inbox`, wherever you will actually see it. One
line in its body is enough:
```
${inbox.pending()}
```
Each row carries a note's name, its destination and its own File button, so
filing several notes does not mean opening each one.
## Deployment
**The server has no authentication of its own.** Any request that reaches it
gets read access to the entire space, plus write access under
`write_prefix`, using the configured token. That is why it binds to the
loopback interface: authentication and TLS belong to a reverse proxy in front
of it.
[`Caddyfile.example`](Caddyfile.example) is a working starting point. The one
non-obvious setting is `flush_interval -1`: MCP streams its responses as
server-sent events, and a proxy that buffers them leaves the client waiting
forever.
Setting `host = "0.0.0.0"` publishes an unauthenticated server on every interface.
Only do it where something else restricts access — a container published as
`-p 127.0.0.1:8000:8000`, or a private network you control.
Note that MCP's own HTTP auth is OAuth bearer-based, so a given client may not
support HTTP Basic. If yours does not, either use a proxy that accepts a bearer
token, or move authentication into the server itself: the SDK takes a
`token_verifier` on `MCPServer(...)` for exactly this.
## Tests
```sh
.venv/bin/pip install -r requirements-dev.txt
.venv/bin/python -m pytest
```
The suite needs no network and no SilverBullet: `tests/fake_space.py` is an
ASGI stand-in for the `/.fs` API that keeps real `ETag` state, so the
conditional requests the write tools depend on are exercised against stored
state rather than against canned responses.
## Known limitations
`search_pages` fetches every page in the space on each call, so search cost
grows linearly with space size.
## License
Apache License 2.0 — see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues