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.
- package/README.md +165 -31
- package/dist/api.d.ts +35 -5
- package/dist/api.js +134 -18
- package/dist/clean.d.ts +123 -0
- package/dist/clean.js +239 -0
- package/dist/config.d.ts +23 -1
- package/dist/config.js +114 -5
- package/dist/filters.d.ts +3 -3
- package/dist/index.js +32 -5
- package/dist/output-schema.d.ts +55 -0
- package/dist/output-schema.js +57 -0
- package/dist/result.d.ts +48 -5
- package/dist/result.js +317 -26
- package/dist/schema.d.ts +25 -0
- package/dist/schema.js +31 -8
- package/dist/server.d.ts +1 -1
- package/dist/server.js +77 -13
- package/dist/shape.d.ts +29 -1
- package/dist/shape.js +79 -9
- package/dist/tools/annotations.d.ts +32 -0
- package/dist/tools/annotations.js +33 -0
- package/dist/tools/catalogue.d.ts +2 -2
- package/dist/tools/collections.d.ts +3 -3
- package/dist/tools/collections.js +234 -66
- package/dist/tools/items.d.ts +1 -1
- package/dist/tools/items.js +50 -44
- package/dist/tools/libraries.d.ts +1 -1
- package/dist/tools/libraries.js +144 -87
- package/dist/tools/me.d.ts +1 -1
- package/dist/tools/me.js +83 -48
- package/dist/tools/playlists.d.ts +12 -3
- package/dist/tools/playlists.js +272 -60
- package/dist/tools/progress.d.ts +4 -4
- package/dist/tools/progress.js +144 -44
- package/package.json +22 -17
- package/dist/api.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/confirm.d.ts +0 -36
- package/dist/confirm.js +0 -72
- package/dist/confirm.js.map +0 -1
- package/dist/filters.js.map +0 -1
- package/dist/hosts.d.ts +0 -22
- package/dist/hosts.js +0 -144
- package/dist/hosts.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/result.js.map +0 -1
- package/dist/schema.js.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/shape.js.map +0 -1
- package/dist/tool-filter.d.ts +0 -45
- package/dist/tool-filter.js +0 -171
- package/dist/tool-filter.js.map +0 -1
- package/dist/tools/catalogue.js.map +0 -1
- package/dist/tools/collections.js.map +0 -1
- package/dist/tools/items.js.map +0 -1
- package/dist/tools/libraries.js.map +0 -1
- package/dist/tools/me.js.map +0 -1
- package/dist/tools/playlists.js.map +0 -1
- 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
|
[](https://github.com/ni-c/audiobookshelf-mcp/actions/workflows/ci.yml)
|
|
6
|
+
[](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
|
+
[](https://glama.ai/mcp/servers/ni-c/audiobookshelf-mcp)
|
|
9
|
+
<br>
|
|
4
10
|
[](https://www.npmjs.com/package/audiobookshelf-mcp)
|
|
5
|
-
[](https://audiobookshelf-mcp.ni-c.de)
|
|
11
|
+
[](https://github.com/ni-c/audiobookshelf-mcp/pkgs/container/audiobookshelf-mcp)
|
|
12
|
+
[](https://mcp-hub.ni-c.de)
|
|
13
|
+
<br>
|
|
14
|
+
[](https://audiobookshelf-mcp.ni-c.de)
|
|
10
15
|
[](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
|
-
##
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
|
173
|
-
| `set_media_progress`
|
|
174
|
-
| `delete_media_progress` | Delete a progress record —
|
|
175
|
-
| `create_bookmark` / `update_bookmark` / `delete_bookmark` | Named positions in a book |
|
|
176
|
-
| `create_collection` / `update_collection` / `delete_collection` | Collections
|
|
177
|
-
| `add_books_to_collection` / `remove_books_from_collection` | Collection membership |
|
|
178
|
-
| `create_playlist` / `update_playlist` / `delete_playlist` | Playlists
|
|
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.
|
|
187
|
-
|
|
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
|
-
- **
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
the
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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 #
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
/**
|
package/dist/clean.d.ts
ADDED
|
@@ -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
|
+
};
|