Skip to main content
Glama
shelfmark-mcp

shelfmark-mcp

Official
README.md
# shelfmark-mcp

An [MCP](https://modelcontextprotocol.io) server for
[Shelfmark](https://github.com/calibrain/shelfmark), the self-hosted book and
audiobook downloader. It lets an AI assistant do what a person does in
Shelfmark's web UI: search for a book, look at the releases its sources offer,
pick one, queue the download and follow it until it lands in the library. It
can also work through Shelfmark's request queue, which its Hardcover "Want to
Read" sync fills.

It speaks MCP over streamable HTTP behind a bearer token, talks to Shelfmark's
REST API, and keeps nothing on disk. Built against Shelfmark 1.6.1; 1.5.x has
the same API.

## Tools

The usual flow is `search_books` → `list_releases` → `download_release` →
`download_status`.

Finding a book

| Tool | |
|---|---|
| `shelfmark_status` | Health, version, the enabled release sources and metadata provider, default content type and languages, accepted formats, the release search budget, and whether the request tools work. |
| `search_books` | Search the metadata provider (Hardcover, Open Library, ...) by free text or by author, title or series. Each book has a `book_id`, year, series position, whether the library already holds it, and the provider's ratings and reader counts. |
| `get_book` | One book's full metadata: description, ISBNs, publisher, series, provider link. |
| `list_releases` | Ask the enabled release sources for copies of a book: source, indexer, format, size, language, protocol, seeders and peers, download counts. Filters by format; `expand` broadens the search. |

Downloading

| Tool | |
|---|---|
| `download_release` | Queue a release; returns a `download_id`. |
| `download_status` | The queue now, each download's state and progress; or one download, also found in history. |
| `download_history` | Completed, failed and cancelled downloads, newest first, including ones cleared from the activity list. |
| `cancel_download` | Stop a queued or running download. |
| `retry_download` | Queue a failed or cancelled download again. |

Requests (Shelfmark with user accounts only)

| Tool | |
|---|---|
| `list_requests` | Books people asked for, with who asked, the content type and a `book_id` to list releases for. |
| `fulfil_request` | Approve one with a chosen release, the requester's own pick, or as already in the library. |
| `reject_request` | Decline one, with a note. |

Annotations are honest: the reads are `readOnlyHint`; `download_release`,
`retry_download` and `fulfil_request` start downloads from the internet
(`openWorldHint`); `cancel_download` and `reject_request` are
`destructiveHint`, so a client can ask before them.

## Running it

Requirements: a reachable Shelfmark, and Python 3.12+ with the dependencies in
`pyproject.toml`, or Nix:

```sh
nix run .                # from a checkout; or: nix build
```

| Variable | |
|---|---|
| `MCP_BEARER_TOKEN` | Required, 16+ characters, no whitespace: the token clients present as `Authorization: Bearer …`. The server refuses to start without it. |
| `SHELFMARK_URL` | Required: Shelfmark's address, e.g. `http://shelfmark:8084`, with its base path if it has one (`https://example.org/shelfmark`). |
| `SHELFMARK_API_KEY` | Optional: Shelfmark's `SHELFMARK_API_KEY`, sent as `X-Api-Key`. Needed when Shelfmark has user accounts (any `AUTH_METHOD` but `none`); with it, this server acts as Shelfmark's first admin. |
| `MCP_LISTEN` | Optional, default `0.0.0.0:8080`. |

MCP is served at `/mcp`. `GET /healthz` needs no token and returns no library
data: `ok` (200) only when Shelfmark's `/api/health` answers `ok`, otherwise
503 with a reason (`unavailable: shelfmark_unavailable`, `…_coordinator_stopped`,
`…_http_<status>`).

Logs go to stderr, one line per tool call with its name, outcome, duration and
how many connection attempts had to be repeated. They never carry a token, a
key or an upstream body.

### Behind an MCP gateway

The server is stateless (every POST to `/mcp` is one JSON exchange), so a
gateway can open a session per call. For [LiteLLM](https://docs.litellm.ai):

```yaml
mcp_servers:
  shelfmark:
    url: http://shelfmark-mcp:8080/mcp
    transport: http
    auth_type: bearer_token
    auth_value: <MCP_BEARER_TOKEN>
    description: Search, download and follow books in Shelfmark
```

`list_releases` can take as long as Shelfmark's release search budget (its
`RELEASE_SEARCH_TIMEOUT`, 300 s by default, shown by `shelfmark_status`): give
the gateway's and the client's tool-call timeouts at least that much, or lower
the budget in Shelfmark.

## How it behaves

- **Text from outside is untrusted.** Titles, authors, descriptions, release
  names, notes and status messages come back under an `untrusted` key, and the
  server's instructions tell the model to read them, never follow them.
- **Opaque ids.** `book_id` is Shelfmark's own `provider:id`. Shelfmark's
  download task ids can be a kilobyte long (a torrent source's id embeds its
  magnet link), so `download_id` is a short digest the server resolves against
  Shelfmark's queue and history. A `release_id` names a release from the last
  listing: queueing one takes the whole release as Shelfmark listed it, so
  listings are kept in memory for 12 hours; after that, or a restart, the id is
  `stale_id` and the releases have to be listed again.
- **Downloads are queued the way the web UI queues them**: title, authors,
  year, cover and series from the book's metadata, everything else from the
  release as listed.
- **Connections are retried, requests are not.** A new connection to a port
  published from a rootless container's network namespace sometimes stalls on
  the SYN and fails only after ~10 s. Each request connects with escalating
  timeouts (0.4, 0.8, 1.5, 2.5 s) and retries only a failed connection, where
  nothing was sent, a POST included. A request that was sent is never repeated:
  no answer is `no_answer` (for a write, `outcome: unknown`: check
  `download_status` before repeating it), and an HTTP error is Shelfmark
  answering, returned as a typed error.
- **Typed errors.** `{"error": kind, "message": …}`, with Shelfmark's status
  and code where it gave one: `shelfmark_unavailable`, `no_answer`,
  `auth_failed`, `forbidden`, `refused_by_policy`, `requests_unavailable`,
  `source_unavailable`, `not_found`, `conflict`, `already_queued`,
  `invalid_argument`, `invalid_id`, `stale_id`, `shelfmark_error`.

## Shelfmark's behaviour it is built around

From reading Shelfmark 1.6.1 (and 1.5.1); the tests use answers shaped like a
real instance's:

- `GET /api/releases` is synchronous and searches each enabled source in turn
  under one wall-clock budget, so it is slow; when every source fails it
  answers 503 with the first source's error.
- `POST /api/releases/download` wants the release as listed plus the book's
  metadata, and answers a duplicate with HTTP 500 "Release is already in the
  download queue". The task id is the release's `source_id`.
- `GET /api/status` holds the live queue; finished downloads also live in the
  activity snapshot and, once cleared in the UI, in `/api/activity/history`.
  JSON keys come back sorted, so order comes from timestamps.
- A release's `content_type` is the source's label (for example
  `📕 book (fiction)`), not `ebook` or `audiobook`; the content type searched
  for is what the download is queued as.
- The request endpoints answer 403 `requests_unavailable` when Shelfmark runs
  with `AUTH_METHOD=none`, and an API key is ignored in that mode. With user
  accounts, `SHELFMARK_API_KEY` makes each request an admin's.
- The Hardcover sync and its auto-download can run even in `AUTH_METHOD=none`:
  the requests stay out of reach, but their downloads appear in the queue with
  a `request_id`, and with the source's label (`📘 book (non-fiction)`) as
  their content type, which is reported here as `ebook` or `audiobook`.
- 1.6.1's `/api/health` adds `coordinator` and answers 503 when the download
  coordinator has stopped; 1.5.x always answers `{"status": "ok"}`.

## Development

```sh
nix flake check -L      # the test suite (in the package build), ruff and nixfmt
nix fmt                 # format the nix files; ruff format for the Python
```

## License

MIT