calibre-web-mcp
README.md
# calibre-web-mcp
An [MCP](https://modelcontextprotocol.io) server that gives an AI assistant
your [Calibre-Web](https://github.com/janeczku/calibre-web) library, built
for [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated)
(CWA): find books and look them up, see what is new, mark books read or
unread, keep shelves, and send a book to your e-reader.
It logs in to Calibre-Web as one user and uses the same routes the web UI
uses, so that user's permissions apply unchanged. It speaks MCP over
streamable HTTP behind a bearer token and keeps nothing on disk. No tool
deletes a book, edits metadata, or touches users or settings.
## Tools
Read
| Tool | |
|---|---|
| `search_books` | Search, or list the whole library; sorted by date added (newest first), title, author or series; paged. |
| `recent_books` | The books added most recently. |
| `get_book` | Title, authors, series, tags, languages, publisher, dates, rating, formats with sizes, read and archived state, shelves, what can be sent to the e-reader, description. |
| `get_send_status` | This user's e-mail and conversion tasks: waiting, started, finished, failed. |
| `list_shelves` | The shelves the user can see, with book counts, and the magic shelves (read-only, rule-based). |
| `get_shelf` | The books on a shelf, a page at a time. |
Write
| Tool | |
|---|---|
| `set_read_status` | Mark read or unread. Idempotent. |
| `create_shelf` | A private shelf, or a public one if the user may. Returns the existing shelf if there is one with that name. |
| `add_to_shelf`, `remove_from_shelf` | Shelf membership. Idempotent; removing never touches the book. |
| `send_to_ereader` | E-mails the book to the e-reader address in the user's Calibre-Web profile. |
Annotations are honest: `readOnlyHint` on reads, and `destructiveHint` plus
`openWorldHint` on `send_to_ereader`, the one tool whose effect leaves the
library and cannot be taken back. A client can ask before that one.
`search_books` uses Calibre-Web's own search, which matches the query as one
piece of text inside a title, tag, series or publisher, or as words that all
appear in the authors' names. It does not combine fields: "dune herbert"
finds nothing, while "dune" or "frank herbert" do. The tool description says
so, so the model searches for one thing at a time.
## Running it
Requirements: a Calibre-Web or CWA instance, and Python 3.12+ with the
dependencies in `pyproject.toml`, or Nix:
```sh
nix run . # or: nix build, then ./result/bin/calibre-web-mcp
```
The server listens on `MCP_LISTEN` (default `0.0.0.0:8080`), with MCP at `/mcp`
and `GET /healthz`. The health check needs no auth and returns no library
data. It answers `ok` only when Calibre-Web answers an authenticated request,
and `503 unavailable: <kind>` otherwise. Every other request needs
`Authorization: Bearer $MCP_BEARER_TOKEN`.
### Configuration
| Variable | |
|---|---|
| `MCP_BEARER_TOKEN` | Required, 16+ characters: the token MCP clients present. |
| `MCP_LISTEN` | `host:port`, default `0.0.0.0:8080`. |
| `CALIBRE_WEB_URL` | Required: where this server reaches Calibre-Web, e.g. `http://calibre-web:8083`. A path prefix is fine (`https://example.org/calibre`). |
| `CALIBRE_WEB_AUTH_MODE` | Required: `header` or `form` (below). |
| `CALIBRE_WEB_USER` | Required: the Calibre-Web user this server acts as. |
| `CALIBRE_WEB_AUTH_HEADER` | Header mode only: the header name, default `Remote-User`. |
| `CALIBRE_WEB_PASSWORD` or `CALIBRE_WEB_PASSWORD_FILE` | Form mode only: the user's password, or a file holding it. |
The server refuses to start, with a message naming the variable, when a
required setting is missing or when the settings mix the two modes.
### Logging in: two modes
**Header mode** (`CALIBRE_WEB_AUTH_MODE=header`) uses Calibre-Web's reverse
proxy authentication: Admin › Edit Basic Configuration › Feature
Configuration › "Allow Reverse Proxy Authentication", with "Reverse Proxy
Header Name" set to the same value as `CALIBRE_WEB_AUTH_HEADER`. Every request
carries the header with `CALIBRE_WEB_USER` as its value, and Calibre-Web
treats each request as that user. With "Auto-create users from reverse proxy"
on, a name Calibre-Web does not know becomes a new account.
> **Security:** in header mode the header *is* the credential. Anything that
> can reach Calibre-Web and set that header is logged in as whichever user it
> names. Run this server where it reaches Calibre-Web on a network nothing
> else can reach, such as a private container network shared only by the two.
> If Calibre-Web is also published through a reverse proxy, that proxy must
> remove the header from incoming requests.
**Form mode** (`CALIBRE_WEB_AUTH_MODE=form`) logs in through Calibre-Web's
login form once and then uses its session cookie (with "remember me"),
logging in again when the session ends. Calibre-Web rate-limits logins per
username (3 a minute, 40 a day), and reaching that limit locks the person out
of the web UI as well. So after a refused login, or one that got no answer,
this server waits a minute before it tries again, then two minutes, doubling
up to an hour, and reports `auth_failed` in the meantime. Each successful
login is a remembered session in Calibre-Web, which keeps it for 31 days.
### Behind an MCP gateway
The endpoint is stateless and answers in JSON, so each POST to `/mcp` is
complete on its own. That suits gateways that open a session per call, such
as LiteLLM's MCP gateway. Register it as a streamable-HTTP server at
`http://<host>:8080/mcp` with the header `Authorization: Bearer <token>`, and
give the gateway's own keys access to it as you would to any other tool.
Point the gateway's health check, or the container's, at `/healthz`.
## Safety model
- **The library's text is untrusted.** Titles, descriptions, tags, series,
publishers and shelf names often come from internet metadata sources. The
server's instructions tell the model to read them as data and never to
follow them.
- **Sending is guarded.** `send_to_ereader` uses the route behind the book
page's own send button, which mails the user's configured address and
nothing else. It offers only the formats that page offers, and refuses
while a send of the same book is still waiting or running, so a retried
call does not mail the book twice. If the user has no e-reader address, it
says so (`no_ereader_address`) without trying.
- **Writes are idempotent where Calibre-Web's are not.** Calibre-Web only
toggles read status, so the tool reads the state first, toggles only when
it differs, and reads it again to confirm. Adding a book that is already on
a shelf, or removing one that is not, reports `changed: false`.
- **A small, fixed surface.** No route that deletes, edits metadata, changes
users or settings, or uploads is ever called.
- **No state, no background work.** Nothing goes to disk. The only state in
memory is the cookie jar, the CSRF token and the login backoff.
## Calibre-Web behaviour it is built around
These come from reading CWA v4.0.8's source (`cps/web.py`, `shelf.py`,
`helper.py`, `opds.py`, `tasks_status.py`, the templates) and checking
against a running instance:
- **CSRF.** Every POST needs a Flask-WTF token, and the token is valid only
with the session cookie it was issued with. The server takes the token from
the hidden `csrf_token` input that every page carries, sends it the way the
web UI does (form field plus `X-CSRFToken`), and when Calibre-Web answers
its generic 400 page it fetches a fresh token and retries once. That applies
in header mode too: the header logs the user in, but POSTs still need the
cookie and the token.
- **Secure cookies over plain HTTP.** With "use HTTPS" or OAuth configured,
Calibre-Web marks its session cookie `Secure`. A browser-style cookie jar
would never send that cookie to an internal `http://` URL, which would break
every POST. The server keeps its cookies itself.
- **Read status only toggles, for any id.** `POST /ajax/toggleread/<id>`
flips the state, and for an id with no book behind it, it records that
non-existent book as read. The tool checks that the book exists (through its
page) before it ever toggles.
- **Book lists.** `/ajax/listbooks` is the JSON behind the book table. It
joins authors with `&` and tags with `,`, and leaves datetime columns empty
(`atom_timestamp` holds the date added). Without a search, it converts the
offset to a page number through a float, so some offsets are rounded away
(offset 1 with limit 3 returns from 0). The server asks for a page size whose
round trip lands exactly on the offset.
- **Book details.** No JSON endpoint has the per-user state: read, archived,
shelves and send options. Those come from the book page, using element ids,
classes and `data-*` attributes rather than translated text, so the parsing
works in any UI language. Exact sizes and dates come from `/ajax/book/<uuid>`.
That endpoint takes HTTP Basic auth rather than the session, and it ignores
the user's tag restrictions, so tags come from the page. In v4.0.8 the book
page's XHR fragment (`X-Requested-With`) fails with a 500, so the server
fetches the full page.
- **Sending.** `POST /send/<id>/<format>/<convert>` queues an e-mail to every
address in the user's "Send to eReader Email Address" profile setting.
`convert` 1 or 2 first converts MOBI or AZW3 to the format. Sending needs
the download permission. The book page lists only formats under the
configured attachment size limit, and the route answers `[{type, message}]`
with a translated message. The result is a queued task. Its progress shows in `/ajax/emailstat`, which keeps
tasks in memory (a restart clears them) and shows an admin everyone's tasks;
the server keeps only its own user's.
- **Shelves.** Add and remove, sent as XHR, answer with plain status codes:
204 for done, 400 for already on the shelf or an invalid shelf or book, 410
for already removed, and 403 for not allowed. The 400 bodies are fixed
English strings. Creating a shelf redirects to the new shelf on success.
When creation is refused, the reason is a flash message. Viewing a shelf
applies the sort order the user last chose for shelves in the web UI, just
as the web UI does. Magic shelves are rule-based, so they are listed but
never changed.
Plain Calibre-Web shares these routes, but not all of the page markup the
server reads (CWA's book page, the profile flag for a configured e-reader).
It is built and tested against CWA v4.0.8.
## Tests
```sh
nix flake check -L # lint (ruff, nixfmt), then the package build, which runs the suite
```
The suite runs in the package's `checkPhase`:
- configuration: each mode, and each way of mixing or leaving out settings;
- the parsers, on pages and JSON rebuilt from CWA v4.0.8's templates;
- the HTTP session against a mocked Calibre-Web (respx): the header on every
request, the form login and its backoff, CSRF refresh, Secure cookies,
timeouts and failures;
- every tool against that mock, checked for the routes it calls and how it
reads their answers. That includes `send_to_ereader`, which is only ever
tested against the mock;
- the MCP layer through the SDK's own client: tool listing and annotations,
bearer auth, health, typed errors, and one log line per call with no
secrets in the log.
Without Nix: `pip install -e '.[test]'`, then `pytest`.
## License
MIT.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing