audiobookshelf-mcp 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +165 -31
  2. package/dist/api.d.ts +35 -5
  3. package/dist/api.js +134 -18
  4. package/dist/clean.d.ts +123 -0
  5. package/dist/clean.js +239 -0
  6. package/dist/config.d.ts +23 -1
  7. package/dist/config.js +114 -5
  8. package/dist/filters.d.ts +3 -3
  9. package/dist/index.js +32 -5
  10. package/dist/output-schema.d.ts +55 -0
  11. package/dist/output-schema.js +57 -0
  12. package/dist/result.d.ts +48 -5
  13. package/dist/result.js +317 -26
  14. package/dist/schema.d.ts +25 -0
  15. package/dist/schema.js +31 -8
  16. package/dist/server.d.ts +1 -1
  17. package/dist/server.js +77 -13
  18. package/dist/shape.d.ts +29 -1
  19. package/dist/shape.js +79 -9
  20. package/dist/tools/annotations.d.ts +32 -0
  21. package/dist/tools/annotations.js +33 -0
  22. package/dist/tools/catalogue.d.ts +2 -2
  23. package/dist/tools/collections.d.ts +3 -3
  24. package/dist/tools/collections.js +234 -66
  25. package/dist/tools/items.d.ts +1 -1
  26. package/dist/tools/items.js +50 -44
  27. package/dist/tools/libraries.d.ts +1 -1
  28. package/dist/tools/libraries.js +144 -87
  29. package/dist/tools/me.d.ts +1 -1
  30. package/dist/tools/me.js +83 -48
  31. package/dist/tools/playlists.d.ts +12 -3
  32. package/dist/tools/playlists.js +272 -60
  33. package/dist/tools/progress.d.ts +4 -4
  34. package/dist/tools/progress.js +144 -44
  35. package/package.json +22 -17
  36. package/dist/api.js.map +0 -1
  37. package/dist/config.js.map +0 -1
  38. package/dist/confirm.d.ts +0 -36
  39. package/dist/confirm.js +0 -72
  40. package/dist/confirm.js.map +0 -1
  41. package/dist/filters.js.map +0 -1
  42. package/dist/hosts.d.ts +0 -22
  43. package/dist/hosts.js +0 -144
  44. package/dist/hosts.js.map +0 -1
  45. package/dist/index.js.map +0 -1
  46. package/dist/result.js.map +0 -1
  47. package/dist/schema.js.map +0 -1
  48. package/dist/server.js.map +0 -1
  49. package/dist/shape.js.map +0 -1
  50. package/dist/tool-filter.d.ts +0 -45
  51. package/dist/tool-filter.js +0 -171
  52. package/dist/tool-filter.js.map +0 -1
  53. package/dist/tools/catalogue.js.map +0 -1
  54. package/dist/tools/collections.js.map +0 -1
  55. package/dist/tools/items.js.map +0 -1
  56. package/dist/tools/libraries.js.map +0 -1
  57. package/dist/tools/me.js.map +0 -1
  58. package/dist/tools/playlists.js.map +0 -1
  59. package/dist/tools/progress.js.map +0 -1
package/README.md CHANGED
@@ -1,13 +1,19 @@
1
1
  # audiobookshelf-mcp
2
2
 
3
+ <!-- badges: start -->
4
+
3
5
  [![CI](https://img.shields.io/github/actions/workflow/status/ni-c/audiobookshelf-mcp/ci.yml?branch=main&label=CI)](https://github.com/ni-c/audiobookshelf-mcp/actions/workflows/ci.yml)
6
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/ni-c/audiobookshelf-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/ni-c/audiobookshelf-mcp)
7
+ <a href="https://socket.dev/npm/package/audiobookshelf-mcp"><img src="https://socket.dev/api/badge/npm/package/audiobookshelf-mcp" alt="Socket supply-chain report" height="20"></a>
8
+ [![Glama score](https://glama.ai/mcp/servers/ni-c/audiobookshelf-mcp/badges/score.svg)](https://glama.ai/mcp/servers/ni-c/audiobookshelf-mcp)
9
+ <br>
4
10
  [![npm version](https://img.shields.io/npm/v/audiobookshelf-mcp)](https://www.npmjs.com/package/audiobookshelf-mcp)
5
- [![npm downloads](https://img.shields.io/npm/dm/audiobookshelf-mcp)](https://www.npmjs.com/package/audiobookshelf-mcp)
6
- [![node](https://img.shields.io/node/v/audiobookshelf-mcp)](https://nodejs.org)
7
- [![license](https://img.shields.io/npm/l/audiobookshelf-mcp)](LICENSE)
8
- [![container](https://img.shields.io/badge/ghcr.io-ni--c%2Faudiobookshelf--mcp-blue)](https://github.com/ni-c/audiobookshelf-mcp/pkgs/container/audiobookshelf-mcp)
9
- [![docs](https://img.shields.io/badge/docs-audiobookshelf--mcp.ni--c.de-informational)](https://audiobookshelf-mcp.ni-c.de)
11
+ [![container image](https://img.shields.io/badge/ghcr.io-ni--c%2Faudiobookshelf--mcp-4f46e5?logo=docker&logoColor=white)](https://github.com/ni-c/audiobookshelf-mcp/pkgs/container/audiobookshelf-mcp)
12
+ [![HTTP via mcp-hub](https://img.shields.io/badge/HTTP-via%20mcp--hub-4f46e5?logo=modelcontextprotocol&logoColor=white)](https://mcp-hub.ni-c.de)
13
+ <br>
14
+ [![docs](https://img.shields.io/badge/docs-audiobookshelf--mcp.ni--c.de-4f46e5?logo=readthedocs&logoColor=white)](https://audiobookshelf-mcp.ni-c.de)
10
15
  [![sponsor](https://img.shields.io/badge/sponsor-ni--c-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/ni-c)
16
+ <!-- badges: end -->
11
17
 
12
18
  A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for
13
19
  [Audiobookshelf](https://www.audiobookshelf.org/), the self-hosted audiobook and
@@ -35,6 +41,21 @@ reliably from eight than from forty-four — see
35
41
 
36
42
  <img src="https://audiobookshelf-mcp.ni-c.de/demo.gif" alt="Terminal recording: the server reports 44 tools, lists library items as a compact projection, and answers the first delete_collection call with a single-use confirmation token instead of deleting anything" width="800">
37
43
 
44
+ ## What makes it different
45
+
46
+ **Every response is a projection, not the raw object.** An expanded library item
47
+ carries every audio file, track and chapter with full ffprobe metadata. The media
48
+ tools answer with a compact shape instead, and `detail="full"` is there for when
49
+ the raw object really is what you want.
50
+
51
+ **Twenty-nine of the forty-four tools only read.** `AUDIOBOOKSHELF_READ_ONLY=true`
52
+ registers those and nothing else, so a write tool is absent from `tools/list`
53
+ rather than refused when it is called.
54
+
55
+ **The six tools that take something out ask a person first**, through MCP
56
+ elicitation — a dialog the model cannot answer on its behalf, falling back to a
57
+ single-use token bound to the exact targets where the client cannot show one.
58
+
38
59
  ## Requirements
39
60
 
40
61
  - Node.js 22 or newer
@@ -59,6 +80,7 @@ once, at creation.
59
80
  | `AUDIOBOOKSHELF_INSECURE_TLS` | no | `true` accepts self-signed certificates — scoped to this connection, not process-wide |
60
81
  | `AUDIOBOOKSHELF_ALLOW_TOOLS` | no | Comma-separated tool names, `list_*` prefixes, or `essential` for a curated preset |
61
82
  | `AUDIOBOOKSHELF_DENY_TOOLS` | no | Same syntax; removed from whatever `AUDIOBOOKSHELF_ALLOW_TOOLS` left |
83
+ | `ELICITATION` | no | `false` replaces the approval dialog with the two-call token. **Not prefixed** |
62
84
 
63
85
  The server starts without configuration: it completes the MCP handshake and lists
64
86
  its tools, and every call then fails with the setup instructions. That is
@@ -86,7 +108,9 @@ If you run several of these servers at once, [mcp-hub](https://mcp-hub.ni-c.de)
86
108
  is the other answer — its `/hub` endpoint replaces every server's tools with six
87
109
  meta-tools.
88
110
 
89
- ## Install
111
+ ## Installation
112
+
113
+ ### Claude Code
90
114
 
91
115
  ```sh
92
116
  claude mcp add audiobookshelf \
@@ -95,7 +119,9 @@ claude mcp add audiobookshelf \
95
119
  -- npx -y audiobookshelf-mcp
96
120
  ```
97
121
 
98
- Claude Desktop (`claude_desktop_config.json`):
122
+ ### Claude Desktop
123
+
124
+ `claude_desktop_config.json`:
99
125
 
100
126
  ```json
101
127
  {
@@ -112,7 +138,9 @@ Claude Desktop (`claude_desktop_config.json`):
112
138
  }
113
139
  ```
114
140
 
115
- Codex (`~/.codex/config.toml`):
141
+ ### Codex
142
+
143
+ `~/.codex/config.toml`:
116
144
 
117
145
  ```toml
118
146
  [mcp_servers.audiobookshelf]
@@ -121,7 +149,9 @@ args = ["-y", "audiobookshelf-mcp"]
121
149
  env = { AUDIOBOOKSHELF_URL = "https://abs.example.com", AUDIOBOOKSHELF_API_KEY = "…" }
122
150
  ```
123
151
 
124
- Container (multi-arch, with SBOM and build provenance):
152
+ ### Docker
153
+
154
+ Multi-arch, with SBOM and build provenance:
125
155
 
126
156
  ```sh
127
157
  docker run -i --rm \
@@ -135,8 +165,61 @@ publish. More client recipes, including how to keep the key off the `docker run`
135
165
  command line, are in the
136
166
  [client guide](https://audiobookshelf-mcp.ni-c.de/guide/clients).
137
167
 
168
+ ### Through mcp-hub
169
+
170
+ A client that cannot spawn a local process — ChatGPT connectors, Claude on the web,
171
+ Cursor, LibreChat — reaches audiobookshelf-mcp through [mcp-hub](https://mcp-hub.ni-c.de): one
172
+ container serves many stdio MCP servers over Streamable HTTP, with an OAuth 2.1 login
173
+ behind a single password and long-lived tokens for the clients that cannot do OAuth. Its
174
+ `/hub` endpoint puts every server behind six meta-tools, so one connector reaches all of
175
+ them without N×tool schemas in the model's context, and it speaks both protocol revisions
176
+ — a question this server asks travels through it to the person at the far end.
177
+
178
+ Its `/config/mcp.json` uses Claude Code's format, so the entry is the one you already
179
+ have:
180
+
181
+ ```json
182
+ {
183
+ "mcpServers": {
184
+ "audiobookshelf": {
185
+ "command": "npx",
186
+ "args": ["-y", "audiobookshelf-mcp"],
187
+ "env": { "AUDIOBOOKSHELF_ALLOW_TOOLS": "essential" },
188
+ "denyTools": ["delete_*"]
189
+ }
190
+ }
191
+ }
192
+ ```
193
+
194
+ `allowTools` and `denyTools` there are the hub's **own** per-server filter, which is not
195
+ the same thing as `*_ALLOW_TOOLS` in `env` — the difference, and the mistake it invites,
196
+ are in the [client guide](https://audiobookshelf-mcp.ni-c.de/guide/clients#through-mcp-hub).
197
+
138
198
  ## Tools
139
199
 
200
+ Every tool declares an `outputSchema` and answers with `structuredContent`
201
+ alongside the text block, so a client can use the result without parsing prose.
202
+ Seven tools that answered with a sentence — _"Collection col_1 deleted."_ — now
203
+ answer with the fields as well.
204
+
205
+ The tools that report library metadata carry `untrusted: true` and
206
+ `source: "audiobookshelf"` as fields: book descriptions pulled from metadata
207
+ providers, podcast feed summaries and episode titles are all written by someone
208
+ else, and so are the bookmark titles and selected tags of an account and the
209
+ titles of a library's longest and largest items. The rest are without it — an
210
+ id this server was given, a position it was asked to store, the version string
211
+ of the instance, and the library names and folder paths the operator typed.
212
+
213
+ The documents are described as open objects with the top-level keys this server
214
+ builds. `detail: "full"` hands the API record back whole, so the same tool
215
+ answers with far more keys than it names — and the SDK validates each result
216
+ against its schema before it goes out, which is exactly why a strict shape would
217
+ be wrong here.
218
+
219
+ `get_personalized_shelves` answers `{items: [...]}` rather than the bare array
220
+ the API sends: a schema whose root is an array is served to a 2025-era client
221
+ rewritten as `{result: …}`, so it would otherwise answer in two shapes.
222
+
140
223
  ### Reading
141
224
 
142
225
  | Tool | What it does |
@@ -168,23 +251,45 @@ command line, are in the
168
251
 
169
252
  ### Writing
170
253
 
171
- | Tool | What it does |
172
- | --------------------------------------------------------------- | ----------------------------------------------------------------------- |
173
- | `set_media_progress` | Set position, mark finished or unfinished, hide from Continue Listening |
174
- | `delete_media_progress` | Delete a progress record — needs a confirmation token |
175
- | `create_bookmark` / `update_bookmark` / `delete_bookmark` | Named positions in a book |
176
- | `create_collection` / `update_collection` / `delete_collection` | Collections; delete needs a confirmation token |
177
- | `add_books_to_collection` / `remove_books_from_collection` | Collection membership |
178
- | `create_playlist` / `update_playlist` / `delete_playlist` | Playlists; delete needs a confirmation token |
179
- | `add_items_to_playlist` / `remove_items_from_playlist` | Playlist membership |
254
+ | Tool | What it does |
255
+ | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
256
+ | `set_media_progress` | Set position, mark finished or unfinished, hide from Continue Listening |
257
+ | `delete_media_progress` 👤 | Delete a progress record — the listening history of that item |
258
+ | `create_bookmark` / `update_bookmark` / `delete_bookmark` 👤 | Named positions in a book |
259
+ | `create_collection` / `update_collection` / `delete_collection` 👤 | Collections |
260
+ | `add_books_to_collection` / `remove_books_from_collection` 👤 | Collection membership |
261
+ | `create_playlist` / `update_playlist` / `delete_playlist` 👤 | Playlists |
262
+ | `add_items_to_playlist` / `remove_items_from_playlist` 👤 | Playlist membership |
263
+
264
+ 👤 asks a person through MCP elicitation · falls back to a two-call
265
+ `confirm_token` where the client cannot show a dialog.
180
266
 
181
267
  ### Response size
182
268
 
183
269
  Audiobookshelf returns very large objects — an expanded library item carries every
184
270
  audio file, track and chapter with full ffprobe metadata. Every tool that returns
185
271
  media therefore answers with a compact projection by default and accepts
186
- `detail: "full"` for the raw object. List tools are capped at 100 entries per call
187
- and say how to page on when more match.
272
+ `detail: "full"` for the raw object.
273
+
274
+ Three bounds, because one is not enough:
275
+
276
+ - **A response ceiling of 5 MB.** `content-length` is checked before a byte is
277
+ read and a chunked body is counted while reading, so an oversized answer is
278
+ refused rather than parsed.
279
+ - **A result ceiling of 100 000 bytes**, applied in `jsonResult` — so it covers
280
+ `detail: "full"` too. Whole entries are dropped, never characters: a truncated
281
+ document is not a smaller answer, it is an unparseable one. The result then
282
+ carries a `truncated` block naming what to call instead.
283
+ - **A cap on embedded members.** A compact collection or playlist embeds the
284
+ first 25 of its books or entries and reports the real count; `get_collection`
285
+ and `get_playlist` return the whole membership for one of them.
286
+
287
+ `list_library_items` pages properly, with `limit` and `page`. Seven listing tools
288
+ have neither — `list_libraries`, `list_authors`, `list_tags`, `list_genres`,
289
+ `list_collections`, `list_playlists` and `list_bookmarks` — because the
290
+ Audiobookshelf routes behind them return everything in one answer and take no
291
+ paging parameters. `library_id` narrows the two collection routes; the rest are
292
+ bounded by the ceilings above.
188
293
 
189
294
  ### Filtering
190
295
 
@@ -199,20 +304,44 @@ filter_group="progress", filter_value="finished" | "in-progress" | "not-started"
199
304
  filter_group="issues" (standalone, no value)
200
305
  ```
201
306
 
307
+ ## Not exposed, on purpose
308
+
309
+ **No playback.** Pausing, seeking and playing are a session state machine that
310
+ belongs in a real client. `set_media_progress` covers "mark this finished" and
311
+ "jump me to chapter 12"; your phone does the playing.
312
+
313
+ **No administration, even with an admin key.** There is no tool for user
314
+ management, server settings, backups, cache purging, filesystem browsing, library
315
+ or item deletion, metadata rewriting or file uploads. Those endpoints exist in
316
+ Audiobookshelf; they are simply not wired up here, because the blast radius of a
317
+ confused or manipulated model is bounded by the tool list.
318
+
202
319
  ## Safety
203
320
 
204
321
  - **Read-only mode.** `AUDIOBOOKSHELF_READ_ONLY=true` does not register the write
205
322
  tools at all, rather than refusing them at call time.
206
- - **Confirmation tokens.** `delete_collection`, `delete_playlist` and
207
- `delete_media_progress` answer the first call with a single-use token that is
208
- bound to the target id and expires after five minutes; only a second call
209
- carrying that token performs the deletion. A plain `confirm: true` flag could be
210
- set by the model on the first try, or be talked into it by text coming out of
211
- the library. Operations that are cheap to undo removing an item from a
212
- collection, deleting a bookmark are marked destructive but do not require a
213
- token.
323
+ - **A person is asked, not just told.** The eight tools that can take something
324
+ out the three deletes, `delete_bookmark`, `remove_books_from_collection`,
325
+ `remove_items_from_playlist`, and `update_collection` / `update_playlist` when
326
+ they are asked to reorder, which replaces an order nobody can reconstruct
327
+ raise a real dialog through MCP elicitation,
328
+ which the model cannot answer on its behalf. A plain `confirm: true` flag could
329
+ be set by the model on the first try, or be talked into it by text coming out
330
+ of the library.
331
+
332
+ Where the client cannot show a dialog they fall back to a single-use token
333
+ bound to the exact targets and expiring after five minutes. That fallback
334
+ proves the call was made twice with the same arguments and nothing more, and
335
+ the text says so rather than implying somebody approved. `ELICITATION=false`
336
+ takes it deliberately; it never removes the guard. See
337
+ [Asking a person](https://audiobookshelf-mcp.ni-c.de/guide/approval).
338
+
214
339
  - **Confirmation prompts never quote API content.** Collection and playlist names
215
- are user-supplied text and are read by a model, so the prompts name only ids.
340
+ are user-supplied text and are read by a model, so the prompts name ids and
341
+ counts only.
342
+ - **A 200 that is not JSON is an error.** Returning the body as a string made an
343
+ SSO portal or a captive proxy in front of the instance look like an empty
344
+ library rather than like a failure.
216
345
  - **Untrusted content is marked.** Book descriptions come from metadata providers
217
346
  and podcast summaries come from RSS feeds — third parties write them. Every
218
347
  result carrying such content is labelled as data, not instructions.
@@ -234,11 +363,16 @@ One caveat that comes from Audiobookshelf itself: removing the _last_ entry from
234
363
  playlist deletes the playlist. `remove_items_from_playlist` says so in its result
235
364
  when it happens.
236
365
 
366
+ ## Documentation
367
+
368
+ The full guide, tool reference and security notes live at
369
+ **[audiobookshelf-mcp.ni-c.de](https://audiobookshelf-mcp.ni-c.de)** (source in [`docs/`](docs/)).
370
+
237
371
  ## Development
238
372
 
239
373
  ```sh
240
374
  npm install
241
- npm run lint # eslint + prettier --check
375
+ npm run lint # oxlint + prettier --check
242
376
  npm run build # tsc
243
377
  npm test # vitest
244
378
  npm run test:coverage # with thresholds
@@ -289,4 +423,4 @@ rather than a public issue; the policy is in [SECURITY.md](SECURITY.md).
289
423
 
290
424
  ## License
291
425
 
292
- MIT
426
+ [MIT](LICENSE) © Willi Thiel
package/dist/api.d.ts CHANGED
@@ -4,6 +4,36 @@ export declare class AudiobookshelfApiError extends Error {
4
4
  readonly body: string;
5
5
  constructor(status: number, body: string, method: string, path: string);
6
6
  }
7
+ /** Thrown when a response is larger than this server is willing to read. */
8
+ export declare class ResponseTooLargeError extends Error {
9
+ constructor(path: string, limit: number);
10
+ }
11
+ /**
12
+ * Thrown when a 200 carries something other than JSON.
13
+ *
14
+ * Returning the body instead would send an HTML login page into `listFrom`,
15
+ * which finds neither an array nor an envelope and answers `[]` — so
16
+ * `list_libraries` reports "you have no libraries" where the truth is that an
17
+ * SSO portal or a misconfigured reverse proxy answered instead of the API. A
18
+ * swallowed error replaced by a plausible wrong answer is worse than an error.
19
+ */
20
+ export declare class UnexpectedContentTypeError extends Error {
21
+ constructor(path: string, contentType: string);
22
+ }
23
+ export interface RequestOptions {
24
+ /**
25
+ * Accept a body that is not JSON.
26
+ *
27
+ * Only for the five routes that are known to answer `200 text/plain "OK"`
28
+ * rather than a document — verified against 2.29.0:
29
+ * `DELETE /api/collections/{id}`, `DELETE /api/playlists/{id}`,
30
+ * `PATCH /api/me/progress/{id}`, `DELETE /api/me/progress/{id}` and
31
+ * `DELETE /api/me/item/{id}/bookmark/{time}`. Every one of them is a mutation
32
+ * whose caller ignores the value, so the honest thing is to say so at the
33
+ * call site rather than to weaken the check for everything.
34
+ */
35
+ text?: boolean;
36
+ }
7
37
  /** Minimal client for the Audiobookshelf REST API. */
8
38
  export declare class AudiobookshelfApi {
9
39
  private readonly config;
@@ -15,11 +45,11 @@ export declare class AudiobookshelfApi {
15
45
  */
16
46
  private readonly insecureDispatcher?;
17
47
  constructor(config: Config);
18
- request(method: string, path: string, body?: unknown): Promise<unknown>;
19
- get(path: string): Promise<unknown>;
20
- post(path: string, body?: unknown): Promise<unknown>;
21
- patch(path: string, body?: unknown): Promise<unknown>;
22
- delete(path: string): Promise<unknown>;
48
+ request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<unknown>;
49
+ get(path: string, options?: RequestOptions): Promise<unknown>;
50
+ post(path: string, body?: unknown, options?: RequestOptions): Promise<unknown>;
51
+ patch(path: string, body?: unknown, options?: RequestOptions): Promise<unknown>;
52
+ delete(path: string, options?: RequestOptions): Promise<unknown>;
23
53
  }
24
54
  /**
25
55
  * Guards an id that ends up in a URL path. Path traversal here would let a
package/dist/api.js CHANGED
@@ -1,6 +1,26 @@
1
1
  import { Agent, fetch as undiciFetch, } from 'undici';
2
2
  import { missingConfigKeys, missingConfigMessage, } from './config.js';
3
+ import { assertHeaderValue, quoted } from './clean.js';
3
4
  const REQUEST_TIMEOUT_MS = 15_000;
5
+ /**
6
+ * Ceiling on an error body.
7
+ *
8
+ * Its own number, far below the success ceiling: a 401 from a reverse proxy is
9
+ * a login page, and reading five megabytes of it to quote two thousand
10
+ * characters is work an unauthenticated answer should not be able to buy.
11
+ */
12
+ const MAX_ERROR_BYTES = 64 * 1024;
13
+ /**
14
+ * Ceiling on a single upstream response.
15
+ *
16
+ * Not a theoretical number. `/api/collections` has no pagination at all, and a
17
+ * collection carries every one of its books expanded — a shared server with
18
+ * forty collections of three hundred books answers in double-digit megabytes,
19
+ * and `response.text()` followed by `JSON.parse` holds roughly three copies of
20
+ * that at once. The documented 95 kB of `/api/me/listening-stats` is the
21
+ * harmless end of the same range.
22
+ */
23
+ const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
4
24
  export class AudiobookshelfApiError extends Error {
5
25
  status;
6
26
  body;
@@ -11,6 +31,74 @@ export class AudiobookshelfApiError extends Error {
11
31
  this.name = 'AudiobookshelfApiError';
12
32
  }
13
33
  }
34
+ /** Thrown when a response is larger than this server is willing to read. */
35
+ export class ResponseTooLargeError extends Error {
36
+ constructor(path, limit) {
37
+ super(`the Audiobookshelf response for ${path} exceeds the ` +
38
+ `${Math.round(limit / 1024 / 1024)} MB ceiling and was not read. ` +
39
+ 'Narrow the request — most listing tools take limit and page, and ' +
40
+ 'library_id restricts a server-wide listing to one library.');
41
+ this.name = 'ResponseTooLargeError';
42
+ }
43
+ }
44
+ /**
45
+ * Thrown when a 200 carries something other than JSON.
46
+ *
47
+ * Returning the body instead would send an HTML login page into `listFrom`,
48
+ * which finds neither an array nor an envelope and answers `[]` — so
49
+ * `list_libraries` reports "you have no libraries" where the truth is that an
50
+ * SSO portal or a misconfigured reverse proxy answered instead of the API. A
51
+ * swallowed error replaced by a plausible wrong answer is worse than an error.
52
+ */
53
+ export class UnexpectedContentTypeError extends Error {
54
+ constructor(path, contentType) {
55
+ super(`Audiobookshelf answered ${path} with "${contentType || 'no content type'}" ` +
56
+ 'instead of JSON. A 200 that is not JSON usually means something in ' +
57
+ 'front of the instance answered instead of the API — an SSO portal, a ' +
58
+ 'captive proxy or a login page. Check AUDIOBOOKSHELF_URL.');
59
+ this.name = 'UnexpectedContentTypeError';
60
+ }
61
+ }
62
+ /**
63
+ * Reads a response body with a hard byte ceiling.
64
+ *
65
+ * Both halves matter: `content-length` catches an oversized answer before a
66
+ * single byte is read, and the streaming count catches a chunked response,
67
+ * which declares no length at all.
68
+ */
69
+ async function readCapped(response, maxBytes, allowTruncation) {
70
+ const declared = Number(response.headers.get('content-length'));
71
+ if (Number.isFinite(declared) && declared > maxBytes && !allowTruncation) {
72
+ // Nothing has been read yet, so the body can simply be discarded.
73
+ await response.body?.cancel();
74
+ return { text: '', truncated: true };
75
+ }
76
+ const body = response.body;
77
+ if (!body)
78
+ return { text: '', truncated: false };
79
+ const reader = body.getReader();
80
+ const chunks = [];
81
+ let total = 0;
82
+ let truncated = false;
83
+ for (;;) {
84
+ const { done, value } = await reader.read();
85
+ if (done)
86
+ break;
87
+ if (value === undefined)
88
+ continue;
89
+ if (total + value.byteLength > maxBytes) {
90
+ // `maxBytes - total` is exactly the remaining budget, and the `>` above
91
+ // makes an exactly-maxBytes response legal rather than truncated.
92
+ chunks.push(value.subarray(0, maxBytes - total));
93
+ truncated = true;
94
+ await reader.cancel();
95
+ break;
96
+ }
97
+ chunks.push(value);
98
+ total += value.byteLength;
99
+ }
100
+ return { text: Buffer.concat(chunks).toString('utf8'), truncated };
101
+ }
14
102
  /** Minimal client for the Audiobookshelf REST API. */
15
103
  export class AudiobookshelfApi {
16
104
  config;
@@ -30,7 +118,7 @@ export class AudiobookshelfApi {
30
118
  });
31
119
  }
32
120
  }
33
- async request(method, path, body) {
121
+ async request(method, path, body, options = {}) {
34
122
  // The credentials are only required here, not at startup, so the server can
35
123
  // still be started and introspected without them.
36
124
  const missing = missingConfigKeys(this.config);
@@ -53,6 +141,18 @@ export class AudiobookshelfApi {
53
141
  headers['Content-Type'] = 'application/json';
54
142
  init.body = JSON.stringify(body);
55
143
  }
144
+ // Before `fetch` sees them, and after the body branch has added its own.
145
+ // undici refuses a header value carrying a control character with
146
+ // `Headers.append: "<value>" is an invalid header value.` — the whole
147
+ // value, quoted — and this server's Authorization value *is* the API key.
148
+ // That TypeError reaches `run`, which answers with its message, so the key
149
+ // would land in the model's context because of a line break in a pasted
150
+ // credential. `loadConfig` refuses that shape at startup; this is the
151
+ // second half, because a Config can be built without it and the tests do
152
+ // exactly that.
153
+ for (const [name, value] of Object.entries(headers)) {
154
+ assertHeaderValue(name, value);
155
+ }
56
156
  const url = `${this.baseUrl}${path}`;
57
157
  // The insecure dispatcher requires undici's own fetch; the default path uses
58
158
  // the (stubbable) global fetch so tests can intercept it.
@@ -62,32 +162,48 @@ export class AudiobookshelfApi {
62
162
  dispatcher: this.insecureDispatcher,
63
163
  })
64
164
  : await fetch(url, init);
65
- const text = await response.text();
165
+ // The status decides which ceiling applies, before a byte is read. An
166
+ // error body is only ever quoted back after being cut to 2 000 characters,
167
+ // so it gets a ceiling of its own and is truncated rather than refused —
168
+ // that keeps the status code, which is the diagnostic, instead of
169
+ // replacing it with a size complaint. A successful body cannot be
170
+ // truncated: half a JSON document is not a smaller answer.
171
+ const { text, truncated } = await readCapped(response, response.ok ? MAX_RESPONSE_BYTES : MAX_ERROR_BYTES, !response.ok);
66
172
  if (!response.ok) {
67
173
  throw new AudiobookshelfApiError(response.status, text, method, path);
68
174
  }
175
+ if (truncated) {
176
+ throw new ResponseTooLargeError(path, MAX_RESPONSE_BYTES);
177
+ }
178
+ // A body-less success. Several routes answer 200 or 204 with nothing at
179
+ // all, and "nothing" has no content type to check.
180
+ if (text.length === 0)
181
+ return undefined;
182
+ // A route that is known to answer text. Its caller ignores the value.
183
+ if (options.text)
184
+ return text;
69
185
  const contentType = response.headers.get('content-type') ?? '';
70
- if (contentType.includes('application/json')) {
71
- try {
72
- return JSON.parse(text);
73
- }
74
- catch {
75
- return text;
76
- }
186
+ if (!contentType.includes('application/json')) {
187
+ throw new UnexpectedContentTypeError(path, quoted(contentType));
188
+ }
189
+ try {
190
+ return JSON.parse(text);
191
+ }
192
+ catch {
193
+ throw new UnexpectedContentTypeError(path, `${quoted(contentType)} (unparseable)`);
77
194
  }
78
- return text;
79
195
  }
80
- get(path) {
81
- return this.request('GET', path);
196
+ get(path, options) {
197
+ return this.request('GET', path, undefined, options);
82
198
  }
83
- post(path, body) {
84
- return this.request('POST', path, body);
199
+ post(path, body, options) {
200
+ return this.request('POST', path, body, options);
85
201
  }
86
- patch(path, body) {
87
- return this.request('PATCH', path, body);
202
+ patch(path, body, options) {
203
+ return this.request('PATCH', path, body, options);
88
204
  }
89
- delete(path) {
90
- return this.request('DELETE', path);
205
+ delete(path, options) {
206
+ return this.request('DELETE', path, undefined, options);
91
207
  }
92
208
  }
93
209
  /**
@@ -0,0 +1,123 @@
1
+ /**
2
+ * What is done to text on its way out of this server.
3
+ *
4
+ * Every string an Audiobookshelf answer carries was written by somebody else:
5
+ * a title or a narrator's name from the file's own tags, a description from
6
+ * whichever metadata provider the instance queries, an episode summary from a
7
+ * podcast feed, a collection name from whoever shares the server. All of it
8
+ * goes into a model's context. Three things happen here, in one place, so no
9
+ * field is the one a sweep missed:
10
+ *
11
+ * - **C0 and C1 control characters and DEL are removed**, except tab, line feed
12
+ * and carriage return. A terminal escape in a book title repaints the log of
13
+ * whoever reads the tool result; a NUL ends the string early for whatever
14
+ * parses it next. Nothing in this API means anything by them.
15
+ * - **Lone surrogates are repaired.** `"\ud800"` is legal JSON and parses to
16
+ * half a character. `JSON.stringify` writes it back as an escape, so the wire
17
+ * stays valid — and a Python client encoding the text to UTF-8 then raises
18
+ * `UnicodeEncodeError: surrogates not allowed`. `toWellFormed()` replaces the
19
+ * half with U+FFFD, and it runs after every cut, because a cut can split a
20
+ * pair.
21
+ * - **Credentials in URLs are redacted.** A podcast `feedUrl` is the one field
22
+ * here that routinely carries them: a private feed is published as
23
+ * `https://user:token@feeds.example.com/…`, Audiobookshelf stores it as
24
+ * given, and `get_library_item` hands it back.
25
+ *
26
+ * Format characters (bidi marks, joiners, zero-width) are kept. They are
27
+ * content in a title written in Arabic, Hebrew or Hindi, and this server's
28
+ * results are already framed as untrusted where it matters.
29
+ *
30
+ * The character classes are decided by code point in a loop rather than spelled
31
+ * as a regular expression: the editing tools of this family turn a backslash-u
32
+ * escape in a source line into the raw byte, and a raw escape character in this
33
+ * file is exactly what the file exists to keep out of a result.
34
+ */
35
+ /** Whether a string carries anything {@link cleanText} would remove. */
36
+ export declare function hasControl(value: string): boolean;
37
+ /**
38
+ * Strips control characters and repairs lone surrogates.
39
+ *
40
+ * Linear, and cheap on the common case: a string with nothing to remove is
41
+ * returned as it came, after a well-formedness check that costs one pass.
42
+ */
43
+ export declare function cleanText(value: string): string;
44
+ /**
45
+ * Removes credentials from a URL.
46
+ *
47
+ * The pattern stops at the *last* `@` before the path — `[^/?#]*@` — so
48
+ * `https://a@b@host/` loses both, and a path or query that merely contains an
49
+ * `@` is left alone.
50
+ */
51
+ export declare function redactUrl(url: string): string;
52
+ /**
53
+ * {@link cleanText} over a whole structure, with URL redaction on the way.
54
+ *
55
+ * Rebuilds every object with `Object.fromEntries`, so a key of `__proto__` —
56
+ * an own property after `JSON.parse`, and legal JSON from any backend — stays
57
+ * an own property of the copy instead of becoming its prototype. Keys are
58
+ * cleaned as well as values: a key is text a model reads too.
59
+ *
60
+ * Numbers, booleans and null pass through. `undefined` and functions cannot
61
+ * come out of JSON; they are dropped from objects, where `JSON.stringify`
62
+ * would drop them anyway, and written as `null` in arrays, which is also what
63
+ * it would do — so the two channels cannot disagree about them.
64
+ */
65
+ export declare function cleanValue(value: unknown): unknown;
66
+ /**
67
+ * Text written by whatever answered a request, made safe to quote.
68
+ *
69
+ * Audiobookshelf's error bodies are short and worth reading — "Invalid
70
+ * playlist items. Length mismatch" is the whole diagnosis. But the thing that
71
+ * answers is not always Audiobookshelf: a reverse proxy, an SSO portal or an
72
+ * outbound filter writes its own body, and under `AUDIOBOOKSHELF_INSECURE_TLS`
73
+ * so can anything that can reach the address. So the text is stripped, cut and
74
+ * labelled as what it is.
75
+ */
76
+ export declare function upstreamText(text: string, max?: number): string;
77
+ /**
78
+ * A value shortened for a sentence.
79
+ *
80
+ * For the messages that have to name what was rejected — a media type that is
81
+ * not "book", a content type that is not JSON — without letting a hundred
82
+ * kilobytes of the instance's choosing into the model's context.
83
+ */
84
+ export declare function quoted(value: string, max?: number): string;
85
+ /**
86
+ * Refuses a header value the HTTP layer would refuse, without quoting it.
87
+ *
88
+ * undici's own refusal is `Headers.append: "<value>" is an invalid header
89
+ * value.` — the whole value, in a `TypeError` that this server turns into a
90
+ * tool result. The one header this server builds from a secret is
91
+ * `Authorization`, so an API key with a line break in the middle of it — a
92
+ * wrapped paste — would arrive in the model's context by way of an error
93
+ * message. Refusing first means the runtime never gets to quote one.
94
+ *
95
+ * The message names the header and where the offending character sits, which
96
+ * is what someone fixing a pasted credential needs, and nothing else.
97
+ */
98
+ export declare function assertHeaderValue(name: string, value: string): void;
99
+ /** Whether a field of this name holds a credential. */
100
+ export declare function isCredentialKey(key: string): boolean;
101
+ /** What {@link redactCredentials} put in place of a value, and where. */
102
+ export interface RedactionReport {
103
+ /** Dotted paths of the fields that were replaced, in encounter order. */
104
+ readonly removed: string[];
105
+ }
106
+ /**
107
+ * Replaces credential-shaped fields anywhere in a record the API returned.
108
+ *
109
+ * The reason this exists at all is `GET /api/me`. It answers with
110
+ * `User.toOldJSONForBrowser()`, and `MeController.getCurrentUser` calls it
111
+ * without `hideRootToken` — so the document carries `token`, the account's old
112
+ * non-expiring access token, for the root user included. `detail: "full"`
113
+ * hands the raw record on, which put a credential that outlives this process
114
+ * into a model's context and into whatever that model's operator logs.
115
+ *
116
+ * Written as a walk over every pass-through record rather than as a `delete`
117
+ * on that one field: the projection is not the API, and the next release of
118
+ * either is free to add a second one.
119
+ */
120
+ export declare function redactCredentials(value: unknown, report?: RedactionReport, path?: string): {
121
+ value: unknown;
122
+ report: RedactionReport;
123
+ };