Skip to main content
Glama
README.md
# kavita-mcp

An [MCP](https://modelcontextprotocol.io) server that publishes markdown as EPUB
books into a [Kavita](https://www.kavitareader.com) library, and reads your own
reading state there. Claude writes a report, calls `add_document`, and a few
minutes later the book is on your e-reader through Kavita's OPDS catalog.

- The EPUB is built with pandoc and carries everything Kavita needs: title,
  author, summary, language, date and collection. Kavita imports it with its
  folder watcher; nothing else has to happen.
- Files are written through [File Browser](https://filebrowser.org)'s HTTP API
  (the upload service next to Kavita), not a shared volume.
- Reading tools (on deck, unread, want-to-read, mark read) act as the user whose
  Kavita key the server has.
- No database. No book content is ever returned to the model.

Built for and tested against **Kavita 0.9.1** (0.9.1.4) and **File Browser
v2.63**.

## Tools

All ten carry `title`, `readOnlyHint`, `destructiveHint`, `idempotentHint` and
`openWorldHint` annotations.

| Tool | Parameters | What it does |
|---|---|---|
| `add_document` | `markdown`, `title`, `author`="Claude", `collection`, `summary`, `language`="en", `scan_now` | Builds the EPUB and writes it to `Reports/<slug>/<slug>.epub`. The same title replaces the same book (Kavita keeps its series, so reading progress survives). |
| `update_document` | `series_id`, `summary`, `collection`, `scan_now` | Rewrites a report's metadata in its EPUB. `collection` moves the book out of the collection it was published with; `""` clears a field. Reports only. |
| `delete_document` | `series_id`, `confirm`, `scan_now` | Deletes a report's folder. Needs `confirm: true`. Reports only. |
| `list_library` | `collection`, `library`, `unread_only`, `sort` (`added`/`title`/`read`), `page`, `page_size` (≤ 100) | Compact rows across all your libraries, with `total` and `next_page`. |
| `search` | `query`, `limit` | Title search (Kavita doesn't search summaries). |
| `get_document` | `series_id` | Author, summary, collections, word count, pages, progress, added date, file path, want-to-read, Kavita link. |
| `list_collections` | | Collection and library names, to reuse before publishing. |
| `want_to_read` | `series_id`, `action` (`add`/`remove`) | Your "read next" queue, which Kavita's OPDS catalog shows. |
| `reading_status` | `view` (`on_deck`/`unread`/`want_to_read`), `limit` | What you're reading, what you haven't started, your queue. |
| `mark_read` | `series_id` | Marks every page read. |

A row is `{series_id, title, author, library, added, word_count, pages,
read_status, progress_pct}`.

**Images:** only embedded raster images as `data:` URIs (PNG, JPEG, GIF, WebP;
up to `MAX_IMAGE_BYTES` each). Any other image (a URL, a path, SVG) becomes its alt
text; pandoc runs with `--sandbox`, so it never fetches or reads anything else.

**Nothing active reaches the book.** Kavita's web reader puts book pages into the
page without sanitising them, so the EPUB gets only passive content: raw HTML is
dropped except a few bare tags (`<sub>`, `<sup>`, `<br>`, `<b>`, `<i>`, ...), event
handler and `style` attributes are removed, and links keep their target only for
`http(s):`, `mailto:` and `#` (others keep just their text).

**`scan_now`:** asks Kavita to scan the Reports library now (seconds) instead of
waiting for its folder watcher (about 6 minutes). It needs an admin key; with
another key the file is still written and the answer says so.

Deleting the **last** report also removes its series from Kavita directly (admin
key): Kavita removes nothing in a scan that finds no series at all, so it would
otherwise keep listing it until the next report is published. A hidden
`Reports/.kavita-mcp` file keeps the folder from being empty, because Kavita aborts
every scan of an empty library folder.

## Configuration

| Variable | Default | |
|---|---|---|
| `KAVITA_API_KEY` | (required) | A Kavita auth key (user settings → Auth keys). The tools act as its user. |
| `KAVITA_URL` | `http://kavita:5000` | Kavita, as kavita-mcp reaches it |
| `KAVITA_PUBLIC_URL` | unset | Kavita's public URL, for links in answers |
| `KAVITA_REPORTS_PATH` | `/reports` | The Reports library's folder as Kavita sees it |
| `FILEBROWSER_URL` | `http://kavita-upload:8080` | File Browser, with its login method `noauth` |
| `FILEBROWSER_REPORTS_DIR` | `/Reports` | The same folder as File Browser sees it |
| `SCAN_TIMEOUT_SECONDS` | `60` | How long `scan_now` waits for Kavita |
| `MAX_MARKDOWN_BYTES` | `2097152` | Cap on `markdown` (including data URIs) |
| `MAX_IMAGE_BYTES` | `1048576` | Cap on each embedded image |
| `KAVITA_MCP_TRANSPORT` | `streamable-http` | or `stdio` |
| `KAVITA_MCP_HOST`, `KAVITA_MCP_PORT` | `0.0.0.0`, `8000` | The endpoint is `/mcp` |

`GET /healthz` answers `{"kavita": …, "library": …, "upload": …}` (503 if one is
false).

## Deployment

The image is `ghcr.io/jprintz/kavita-mcp:<version>` (amd64 and arm64). It needs a
writable `/tmp` (pandoc); everything else can be read-only.

The layout it expects: one folder that File Browser serves, with a `Books/` and a
`Reports/` folder inside, and Kavita mounting `Reports/` (read-only is enough) as
the folder of a **Book** library:

```
library/            File Browser's root
  Books/            Kavita: /books   (your other books)
  Reports/          Kavita: /reports (library "Reports", type Book)
```

A Podman Quadlet unit, as the author runs it next to Kavita and File Browser on a
network both share:

```ini
[Container]
ContainerName=kavita-mcp
Image=ghcr.io/jprintz/kavita-mcp:0.1.0
Network=edge.network
Secret=kavita_mcp_api_key,type=env,target=KAVITA_API_KEY
Environment=KAVITA_PUBLIC_URL=https://kavita.example.com
UserNS=auto
ReadOnly=true
NoNewPrivileges=true
DropCapability=ALL
```

In Kavita, once:

1. Server settings → turn on **folder watching** (it is off by default).
2. Add a library "Reports": type **Book**, folder `/reports`, folder watching on,
   **manage collections** on (collections come from the EPUB).

**Authentication:** the server has none of its own. Put it behind something that
authenticates every request: the author uses Cloudflare Access with Managed OAuth,
which is what claude.ai custom connectors expect. Don't expose it unauthenticated:
whoever reaches it acts with the Kavita key.

**Logs** carry the tool name, the slug or series ID, the outcome and the duration;
never the key, the markdown, summaries or Kavita's answers.

## Verified Kavita behaviour (0.9.1.4)

Checked against the source at the release commit and a live instance; the
integration tests pin most of it.

1. **EPUB metadata Kavita reads (Book library):** `dc:title` → series name,
   `dc:creator` → writer, `dc:description` → summary, `dc:language`, `dc:date` →
   release year, an EPUB3 collection title (`title-type` collection) → a collection
   owned by the server's first admin, reused by name (only with "manage collections"
   on). `dc:subject` becomes genres; nothing in an EPUB becomes Kavita tags. So no
   metadata API call is needed.
2. **Finding a new series:** `POST /api/Series/v2` with
   `{"field": 24, "comparison": 0, "value": "/reports/<slug>"}` (the series folder).
   `Library/scan-folder` waits a fixed minute and takes the key only in its body;
   `POST /api/Library/scan?libraryId=` starts right away, so `scan_now` uses that.
3. **Folder watching** is off by default (server setting). When on, a new or
   deleted folder shows up after about 6 minutes (5 minutes' debounce, then a scan
   1 minute later). It picks up writes from another container on the same host.
4. **Requests:** enums are integers; `Series/v2` returns the total only in the
   `Pagination` response header; collections: `Collection/update-for-series` with id
   0 always creates a new collection, so kavita-mcp looks one up by name first;
   `Collection/update-series` with `seriesIdsToRemove` removes.
5. **Permissions:** scanning (`Library/scan`, `Series/scan`, `scan-folder`),
   series metadata edits and series deletes are admin-only. Reading, search,
   want-to-read, progress and the user's own collections work with any key.
   Want-to-read, progress and collections are per user, so use the key of the
   person who reads.
6. **Auth:** the key goes in the `x-api-key` header on every call; no token
   exchange. A bad key gets 401 with an empty body.
7. **Overwriting an EPUB** keeps the series ID (same title) and updates it in
   place. Two catches, both handled:
   - Kavita re-reads a file only when its mtime, **cut to the minute**, is later
     than the book's last scan. A change within the same minute is ignored unless
     the scan is forced. kavita-mcp forces a re-read of that one book (`Series/scan`)
     on `scan_now`, and also without it when Kavita scanned the book less than two
     minutes ago.
   - Kavita drops a library-scan request while a scan of that library is still
     running, so `scan_now` repeats the request while it waits.

   A changed title makes a new series (progress and collections stay with the old
   one, which disappears), so `update_document` never changes titles.
8. **Collections** work without Kavita+. They are private to their owner unless
   promoted. A user's own collections show in their OPDS catalog.

9. **Deleting:** a series whose folder is gone disappears on the next scan, but
   only if that scan finds at least one other series; Kavita aborts scans of an
   empty library folder outright. `DELETE /api/Series/{id}` alone, with the file
   still there, comes back on the next scan.
10. **Kavita's web reader** inserts book pages with Angular's sanitiser bypassed
    (`bypassSecurityTrustHtml`), so anything active in an EPUB would run in the
    reader's session; see "Nothing active reaches the book" above.

Also found: Kavita cuts titles with a volume marker ("Report v2 Final" becomes
series "Report", volume 2) and merges every such book into one series. For those
titles the EPUB names its series explicitly (`calibre:series` = the title, with
Kavita's loose-leaf index), which keeps the title and a single book. Folders loose
in a library's root are ignored, hence one folder per book.

## Development

```bash
uv sync --all-extras
make test            # unit tests; set PANDOC (pandoc 3.11) and EPUBCHECK_JAR for the EPUB ones
make integration     # throwaway Kavita + File Browser + this image, then the acceptance tests
make throwaway-down
```

`scripts/throwaway.sh` starts Kavita 0.9.1 and File Browser laid out like the
deployment above, creates an admin, both libraries, a non-admin user and their
keys (in `.throwaway/env`, git-ignored). `scripts/record_fixtures.py` re-records the
Kavita responses the unit tests use. `uv run pytest -m watch` checks folder
watching on its own (about 7 minutes). In CI the integration workflow runs only when
started by hand.

## License

MIT