Skip to main content
Glama
calibre-web-mcp

calibre-web-mcp

by daedalus-sh
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: these change nothing.

| 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 (rule-based; no tool changes them). |
| `get_shelf` | The books on a shelf, a page at a time. |

Write: what each one changes, and how it is undone.

| Tool | Effect | Undo |
|---|---|---|
| `set_read_status` | Sets this user's read flag on a book. Idempotent. | Call it again with the other value. |
| `create_shelf` | Creates an empty shelf, private, or public if the user may. Returns the existing shelf if there is one with that name and visibility. | Delete the shelf in the web UI; no tool deletes one. |
| `add_to_shelf` | Puts a book at the end of a shelf. Idempotent. | `remove_from_shelf`. |
| `remove_from_shelf` | Takes a book off a shelf; the book itself is untouched. Idempotent. | `add_to_shelf`, which puts it back at the end of the shelf's order. |
| `send_to_ereader` | E-mails the book file now to the e-reader address in the user's Calibre-Web profile. | None: a sent e-mail cannot be recalled. |

Each tool's description opens with the same: a read says it is read-only, and
a write says what it changes and whether it can be undone. The annotations
match:

| | `readOnlyHint` | `destructiveHint` | `idempotentHint` | `openWorldHint` |
|---|---|---|---|---|
| the six reads | true | false | true | false |
| `set_read_status`, `create_shelf`, `add_to_shelf` | false | false | true | false |
| `remove_from_shelf` | false | true | true | false |
| `send_to_ereader` | false | true | false | true |

`remove_from_shelf` is destructive because it deletes the book's place on the
shelf; putting it back puts it at the end. `send_to_ereader` is the one tool
whose effect leaves the library: it cannot be taken back, and each call sends
another e-mail. A client can ask before either.

### Results: what is untrusted

Every text written by someone other than the person using the assistant
comes back under an `untrusted` key, next to the ids it belongs to:

- books: title, authors, series, tags, publisher(s) and the description;
- shelves: the name. Public shelves are named by other users. The user's own
  shelves follow the same rule, so a shelf is always shaped the same way;
- tasks: the kind, description and error (Calibre-Web builds the kind and the
  description from one message, which carries the book's title);
- whatever Calibre-Web says back: the answer of its send route, and in errors
  its flash messages and response bodies (cut to 300 characters).

Ids, counts, flags, dates, ratings, formats, file sizes and language names
stay at the top level. The language names are Calibre-Web's own names for
ISO language codes. A book in a list looks like this:

```json
{
  "id": 164,
  "series_index": 2,
  "languages": ["English"],
  "read": false,
  "added": "2026-07-21T05:23:52+00:00",
  "untrusted": {"title": "…", "authors": ["…"], "series": "…", "tags": ["…"]}
}
```

An error is `{"error": <kind>, "message": ...}`. The message is this server's
own text, and anything Calibre-Web said is under the error's `untrusted`.

`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 or
  other users. They come back under an `untrusted` key (see
  [Results](#results-what-is-untrusted)), and the server's instructions name
  that key and tell the model to read what is under it as data and never to
  follow it.
- **Nothing from a page goes into a URL unchecked.** Every request carries
  this server's login, and in header mode that is a header Calibre-Web trusts
  outright. So a book's uuid from its page must be a UUID before
  `/ajax/book/<uuid>` is asked for, and a send format must be letters and
  digits (with a convert value of 0, 1 or 2) before it goes into
  `/send/<id>/<format>/<convert>`; anything else is dropped or refused. Each
  value goes in as one path segment with every reserved character escaped,
  `/` included. Book and shelf ids are integers.
- **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. Adding gives the book the shelf's highest order plus one,
  and removing deletes its entry, so a book taken off and put back lands at
  the end. Creating a shelf redirects to the new shelf on success.
  When creation is refused, the reason is a flash message. Viewing a shelf
  (`/shelf/<id>/stored/<page>`) applies the sort order the user last chose
  for shelves in the web UI, just as the web UI does, and saves nothing.
  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 URLs built from page values: uuids and send formats that are not plain
  are never requested;
- the `untrusted` split: a marker planted in every outside-written field of
  every tool's answer, and in Calibre-Web's error text, shows up only under an
  `untrusted` key;
- the MCP layer through the SDK's own client: tool listing, annotations and
  descriptions, 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.