audiobookshelf-mcp 0.2.0 → 0.3.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 +153 -26
- package/dist/api.d.ts +35 -5
- package/dist/api.js +112 -18
- package/dist/api.js.map +1 -1
- package/dist/config.d.ts +23 -1
- package/dist/config.js +43 -3
- package/dist/config.js.map +1 -1
- package/dist/filters.d.ts +3 -3
- package/dist/index.js +32 -5
- package/dist/index.js.map +1 -1
- package/dist/output-schema.d.ts +55 -0
- package/dist/output-schema.js +57 -0
- package/dist/output-schema.js.map +1 -0
- package/dist/result.d.ts +53 -5
- package/dist/result.js +182 -10
- package/dist/result.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +36 -11
- package/dist/server.js.map +1 -1
- package/dist/shape.d.ts +1 -1
- package/dist/shape.js +27 -2
- package/dist/shape.js.map +1 -1
- package/dist/tools/annotations.d.ts +32 -0
- package/dist/tools/annotations.js +33 -0
- package/dist/tools/annotations.js.map +1 -0
- package/dist/tools/catalogue.d.ts +2 -2
- package/dist/tools/collections.d.ts +3 -3
- package/dist/tools/collections.js +184 -47
- package/dist/tools/collections.js.map +1 -1
- package/dist/tools/items.d.ts +1 -1
- package/dist/tools/items.js +19 -13
- package/dist/tools/items.js.map +1 -1
- package/dist/tools/libraries.d.ts +1 -1
- package/dist/tools/libraries.js +74 -41
- package/dist/tools/libraries.js.map +1 -1
- package/dist/tools/me.d.ts +1 -1
- package/dist/tools/me.js +48 -29
- package/dist/tools/me.js.map +1 -1
- package/dist/tools/playlists.d.ts +3 -3
- package/dist/tools/playlists.js +172 -42
- package/dist/tools/playlists.js.map +1 -1
- package/dist/tools/progress.d.ts +4 -4
- package/dist/tools/progress.js +121 -34
- package/dist/tools/progress.js.map +1 -1
- package/package.json +16 -11
- package/dist/confirm.d.ts +0 -36
- package/dist/confirm.js +0 -72
- package/dist/confirm.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/tool-filter.d.ts +0 -45
- package/dist/tool-filter.js +0 -171
- package/dist/tool-filter.js.map +0 -1
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://github.com/ni-c/audiobookshelf-mcp/pkgs/container/audiobookshelf-mcp)
|
|
9
9
|
[](https://audiobookshelf-mcp.ni-c.de)
|
|
10
|
+
[](https://mcp-hub.ni-c.de)
|
|
10
11
|
[](https://github.com/sponsors/ni-c)
|
|
11
12
|
|
|
12
13
|
A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for
|
|
@@ -35,6 +36,21 @@ reliably from eight than from forty-four — see
|
|
|
35
36
|
|
|
36
37
|
<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
38
|
|
|
39
|
+
## What makes it different
|
|
40
|
+
|
|
41
|
+
**Every response is a projection, not the raw object.** An expanded library item
|
|
42
|
+
carries every audio file, track and chapter with full ffprobe metadata. The media
|
|
43
|
+
tools answer with a compact shape instead, and `detail="full"` is there for when
|
|
44
|
+
the raw object really is what you want.
|
|
45
|
+
|
|
46
|
+
**Twenty-nine of the forty-four tools only read.** `AUDIOBOOKSHELF_READ_ONLY=true`
|
|
47
|
+
registers those and nothing else, so a write tool is absent from `tools/list`
|
|
48
|
+
rather than refused when it is called.
|
|
49
|
+
|
|
50
|
+
**The six tools that take something out ask a person first**, through MCP
|
|
51
|
+
elicitation — a dialog the model cannot answer on its behalf, falling back to a
|
|
52
|
+
single-use token bound to the exact targets where the client cannot show one.
|
|
53
|
+
|
|
38
54
|
## Requirements
|
|
39
55
|
|
|
40
56
|
- Node.js 22 or newer
|
|
@@ -59,6 +75,7 @@ once, at creation.
|
|
|
59
75
|
| `AUDIOBOOKSHELF_INSECURE_TLS` | no | `true` accepts self-signed certificates — scoped to this connection, not process-wide |
|
|
60
76
|
| `AUDIOBOOKSHELF_ALLOW_TOOLS` | no | Comma-separated tool names, `list_*` prefixes, or `essential` for a curated preset |
|
|
61
77
|
| `AUDIOBOOKSHELF_DENY_TOOLS` | no | Same syntax; removed from whatever `AUDIOBOOKSHELF_ALLOW_TOOLS` left |
|
|
78
|
+
| `ELICITATION` | no | `false` replaces the approval dialog with the two-call token. **Not prefixed** |
|
|
62
79
|
|
|
63
80
|
The server starts without configuration: it completes the MCP handshake and lists
|
|
64
81
|
its tools, and every call then fails with the setup instructions. That is
|
|
@@ -86,7 +103,9 @@ If you run several of these servers at once, [mcp-hub](https://mcp-hub.ni-c.de)
|
|
|
86
103
|
is the other answer — its `/hub` endpoint replaces every server's tools with six
|
|
87
104
|
meta-tools.
|
|
88
105
|
|
|
89
|
-
##
|
|
106
|
+
## Installation
|
|
107
|
+
|
|
108
|
+
### Claude Code
|
|
90
109
|
|
|
91
110
|
```sh
|
|
92
111
|
claude mcp add audiobookshelf \
|
|
@@ -95,7 +114,9 @@ claude mcp add audiobookshelf \
|
|
|
95
114
|
-- npx -y audiobookshelf-mcp
|
|
96
115
|
```
|
|
97
116
|
|
|
98
|
-
Claude Desktop
|
|
117
|
+
### Claude Desktop
|
|
118
|
+
|
|
119
|
+
`claude_desktop_config.json`:
|
|
99
120
|
|
|
100
121
|
```json
|
|
101
122
|
{
|
|
@@ -112,7 +133,9 @@ Claude Desktop (`claude_desktop_config.json`):
|
|
|
112
133
|
}
|
|
113
134
|
```
|
|
114
135
|
|
|
115
|
-
Codex
|
|
136
|
+
### Codex
|
|
137
|
+
|
|
138
|
+
`~/.codex/config.toml`:
|
|
116
139
|
|
|
117
140
|
```toml
|
|
118
141
|
[mcp_servers.audiobookshelf]
|
|
@@ -121,7 +144,9 @@ args = ["-y", "audiobookshelf-mcp"]
|
|
|
121
144
|
env = { AUDIOBOOKSHELF_URL = "https://abs.example.com", AUDIOBOOKSHELF_API_KEY = "…" }
|
|
122
145
|
```
|
|
123
146
|
|
|
124
|
-
|
|
147
|
+
### Docker
|
|
148
|
+
|
|
149
|
+
Multi-arch, with SBOM and build provenance:
|
|
125
150
|
|
|
126
151
|
```sh
|
|
127
152
|
docker run -i --rm \
|
|
@@ -135,8 +160,59 @@ publish. More client recipes, including how to keep the key off the `docker run`
|
|
|
135
160
|
command line, are in the
|
|
136
161
|
[client guide](https://audiobookshelf-mcp.ni-c.de/guide/clients).
|
|
137
162
|
|
|
163
|
+
### Through mcp-hub
|
|
164
|
+
|
|
165
|
+
A client that cannot spawn a local process — ChatGPT connectors, Claude on the web,
|
|
166
|
+
Cursor, LibreChat — reaches audiobookshelf-mcp through [mcp-hub](https://mcp-hub.ni-c.de): one
|
|
167
|
+
container serves many stdio MCP servers over Streamable HTTP, with an OAuth 2.1 login
|
|
168
|
+
behind a single password and long-lived tokens for the clients that cannot do OAuth. Its
|
|
169
|
+
`/hub` endpoint puts every server behind six meta-tools, so one connector reaches all of
|
|
170
|
+
them without N×tool schemas in the model's context, and it speaks both protocol revisions
|
|
171
|
+
— a question this server asks travels through it to the person at the far end.
|
|
172
|
+
|
|
173
|
+
Its `/config/mcp.json` uses Claude Code's format, so the entry is the one you already
|
|
174
|
+
have:
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
{
|
|
178
|
+
"mcpServers": {
|
|
179
|
+
"audiobookshelf": {
|
|
180
|
+
"command": "npx",
|
|
181
|
+
"args": ["-y", "audiobookshelf-mcp"],
|
|
182
|
+
"env": { "AUDIOBOOKSHELF_ALLOW_TOOLS": "essential" },
|
|
183
|
+
"denyTools": ["delete_*"]
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`allowTools` and `denyTools` there are the hub's **own** per-server filter, which is not
|
|
190
|
+
the same thing as `*_ALLOW_TOOLS` in `env` — the difference, and the mistake it invites,
|
|
191
|
+
are in the [client guide](https://audiobookshelf-mcp.ni-c.de/guide/clients#through-mcp-hub).
|
|
192
|
+
|
|
138
193
|
## Tools
|
|
139
194
|
|
|
195
|
+
Every tool declares an `outputSchema` and answers with `structuredContent`
|
|
196
|
+
alongside the text block, so a client can use the result without parsing prose.
|
|
197
|
+
Seven tools that answered with a sentence — _"Collection col_1 deleted."_ — now
|
|
198
|
+
answer with the fields as well.
|
|
199
|
+
|
|
200
|
+
The tools that report library metadata carry `untrusted: true` and
|
|
201
|
+
`source: "audiobookshelf"` as fields: book descriptions pulled from metadata
|
|
202
|
+
providers, podcast feed summaries and episode titles are all written by someone
|
|
203
|
+
else. The rest are without it — an id this server was given, the account it
|
|
204
|
+
authenticates as, counters the instance keeps about itself.
|
|
205
|
+
|
|
206
|
+
The documents are described as open objects with the top-level keys this server
|
|
207
|
+
builds. `detail: "full"` hands the API record back whole, so the same tool
|
|
208
|
+
answers with far more keys than it names — and the SDK validates each result
|
|
209
|
+
against its schema before it goes out, which is exactly why a strict shape would
|
|
210
|
+
be wrong here.
|
|
211
|
+
|
|
212
|
+
`get_personalized_shelves` answers `{items: [...]}` rather than the bare array
|
|
213
|
+
the API sends: a schema whose root is an array is served to a 2025-era client
|
|
214
|
+
rewritten as `{result: …}`, so it would otherwise answer in two shapes.
|
|
215
|
+
|
|
140
216
|
### Reading
|
|
141
217
|
|
|
142
218
|
| Tool | What it does |
|
|
@@ -168,23 +244,45 @@ command line, are in the
|
|
|
168
244
|
|
|
169
245
|
### Writing
|
|
170
246
|
|
|
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 |
|
|
247
|
+
| Tool | What it does |
|
|
248
|
+
| ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
|
|
249
|
+
| `set_media_progress` | Set position, mark finished or unfinished, hide from Continue Listening |
|
|
250
|
+
| `delete_media_progress` 👤 | Delete a progress record — the listening history of that item |
|
|
251
|
+
| `create_bookmark` / `update_bookmark` / `delete_bookmark` 👤 | Named positions in a book |
|
|
252
|
+
| `create_collection` / `update_collection` / `delete_collection` 👤 | Collections |
|
|
253
|
+
| `add_books_to_collection` / `remove_books_from_collection` 👤 | Collection membership |
|
|
254
|
+
| `create_playlist` / `update_playlist` / `delete_playlist` 👤 | Playlists |
|
|
255
|
+
| `add_items_to_playlist` / `remove_items_from_playlist` 👤 | Playlist membership |
|
|
256
|
+
|
|
257
|
+
👤 asks a person through MCP elicitation · falls back to a two-call
|
|
258
|
+
`confirm_token` where the client cannot show a dialog.
|
|
180
259
|
|
|
181
260
|
### Response size
|
|
182
261
|
|
|
183
262
|
Audiobookshelf returns very large objects — an expanded library item carries every
|
|
184
263
|
audio file, track and chapter with full ffprobe metadata. Every tool that returns
|
|
185
264
|
media therefore answers with a compact projection by default and accepts
|
|
186
|
-
`detail: "full"` for the raw object.
|
|
187
|
-
|
|
265
|
+
`detail: "full"` for the raw object.
|
|
266
|
+
|
|
267
|
+
Three bounds, because one is not enough:
|
|
268
|
+
|
|
269
|
+
- **A response ceiling of 5 MB.** `content-length` is checked before a byte is
|
|
270
|
+
read and a chunked body is counted while reading, so an oversized answer is
|
|
271
|
+
refused rather than parsed.
|
|
272
|
+
- **A result ceiling of 100 000 bytes**, applied in `jsonResult` — so it covers
|
|
273
|
+
`detail: "full"` too. Whole entries are dropped, never characters: a truncated
|
|
274
|
+
document is not a smaller answer, it is an unparseable one. The result then
|
|
275
|
+
carries a `truncated` block naming what to call instead.
|
|
276
|
+
- **A cap on embedded members.** A compact collection or playlist embeds the
|
|
277
|
+
first 25 of its books or entries and reports the real count; `get_collection`
|
|
278
|
+
and `get_playlist` return the whole membership for one of them.
|
|
279
|
+
|
|
280
|
+
`list_library_items` pages properly, with `limit` and `page`. Seven listing tools
|
|
281
|
+
have neither — `list_libraries`, `list_authors`, `list_tags`, `list_genres`,
|
|
282
|
+
`list_collections`, `list_playlists` and `list_bookmarks` — because the
|
|
283
|
+
Audiobookshelf routes behind them return everything in one answer and take no
|
|
284
|
+
paging parameters. `library_id` narrows the two collection routes; the rest are
|
|
285
|
+
bounded by the ceilings above.
|
|
188
286
|
|
|
189
287
|
### Filtering
|
|
190
288
|
|
|
@@ -199,20 +297,44 @@ filter_group="progress", filter_value="finished" | "in-progress" | "not-started"
|
|
|
199
297
|
filter_group="issues" (standalone, no value)
|
|
200
298
|
```
|
|
201
299
|
|
|
300
|
+
## Not exposed, on purpose
|
|
301
|
+
|
|
302
|
+
**No playback.** Pausing, seeking and playing are a session state machine that
|
|
303
|
+
belongs in a real client. `set_media_progress` covers "mark this finished" and
|
|
304
|
+
"jump me to chapter 12"; your phone does the playing.
|
|
305
|
+
|
|
306
|
+
**No administration, even with an admin key.** There is no tool for user
|
|
307
|
+
management, server settings, backups, cache purging, filesystem browsing, library
|
|
308
|
+
or item deletion, metadata rewriting or file uploads. Those endpoints exist in
|
|
309
|
+
Audiobookshelf; they are simply not wired up here, because the blast radius of a
|
|
310
|
+
confused or manipulated model is bounded by the tool list.
|
|
311
|
+
|
|
202
312
|
## Safety
|
|
203
313
|
|
|
204
314
|
- **Read-only mode.** `AUDIOBOOKSHELF_READ_ONLY=true` does not register the write
|
|
205
315
|
tools at all, rather than refusing them at call time.
|
|
206
|
-
- **
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
the
|
|
212
|
-
|
|
213
|
-
|
|
316
|
+
- **A person is asked, not just told.** The eight tools that can take something
|
|
317
|
+
out — the three deletes, `delete_bookmark`, `remove_books_from_collection`,
|
|
318
|
+
`remove_items_from_playlist`, and `update_collection` / `update_playlist` when
|
|
319
|
+
they are asked to reorder, which replaces an order nobody can reconstruct —
|
|
320
|
+
raise a real dialog through MCP elicitation,
|
|
321
|
+
which the model cannot answer on its behalf. A plain `confirm: true` flag could
|
|
322
|
+
be set by the model on the first try, or be talked into it by text coming out
|
|
323
|
+
of the library.
|
|
324
|
+
|
|
325
|
+
Where the client cannot show a dialog they fall back to a single-use token
|
|
326
|
+
bound to the exact targets and expiring after five minutes. That fallback
|
|
327
|
+
proves the call was made twice with the same arguments and nothing more, and
|
|
328
|
+
the text says so rather than implying somebody approved. `ELICITATION=false`
|
|
329
|
+
takes it deliberately; it never removes the guard. See
|
|
330
|
+
[Asking a person](https://audiobookshelf-mcp.ni-c.de/guide/approval).
|
|
331
|
+
|
|
214
332
|
- **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
|
|
333
|
+
are user-supplied text and are read by a model, so the prompts name ids and
|
|
334
|
+
counts only.
|
|
335
|
+
- **A 200 that is not JSON is an error.** Returning the body as a string made an
|
|
336
|
+
SSO portal or a captive proxy in front of the instance look like an empty
|
|
337
|
+
library rather than like a failure.
|
|
216
338
|
- **Untrusted content is marked.** Book descriptions come from metadata providers
|
|
217
339
|
and podcast summaries come from RSS feeds — third parties write them. Every
|
|
218
340
|
result carrying such content is labelled as data, not instructions.
|
|
@@ -234,11 +356,16 @@ One caveat that comes from Audiobookshelf itself: removing the _last_ entry from
|
|
|
234
356
|
playlist deletes the playlist. `remove_items_from_playlist` says so in its result
|
|
235
357
|
when it happens.
|
|
236
358
|
|
|
359
|
+
## Documentation
|
|
360
|
+
|
|
361
|
+
The full guide, tool reference and security notes live at
|
|
362
|
+
**[audiobookshelf-mcp.ni-c.de](https://audiobookshelf-mcp.ni-c.de)** (source in [`docs/`](docs/)).
|
|
363
|
+
|
|
237
364
|
## Development
|
|
238
365
|
|
|
239
366
|
```sh
|
|
240
367
|
npm install
|
|
241
|
-
npm run lint #
|
|
368
|
+
npm run lint # oxlint + prettier --check
|
|
242
369
|
npm run build # tsc
|
|
243
370
|
npm test # vitest
|
|
244
371
|
npm run test:coverage # with thresholds
|
|
@@ -289,4 +416,4 @@ rather than a public issue; the policy is in [SECURITY.md](SECURITY.md).
|
|
|
289
416
|
|
|
290
417
|
## License
|
|
291
418
|
|
|
292
|
-
MIT
|
|
419
|
+
[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,17 @@
|
|
|
1
1
|
import { Agent, fetch as undiciFetch, } from 'undici';
|
|
2
2
|
import { missingConfigKeys, missingConfigMessage, } from './config.js';
|
|
3
3
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
4
|
+
/**
|
|
5
|
+
* Ceiling on a single upstream response.
|
|
6
|
+
*
|
|
7
|
+
* Not a theoretical number. `/api/collections` has no pagination at all, and a
|
|
8
|
+
* collection carries every one of its books expanded — a shared server with
|
|
9
|
+
* forty collections of three hundred books answers in double-digit megabytes,
|
|
10
|
+
* and `response.text()` followed by `JSON.parse` holds roughly three copies of
|
|
11
|
+
* that at once. The documented 95 kB of `/api/me/listening-stats` is the
|
|
12
|
+
* harmless end of the same range.
|
|
13
|
+
*/
|
|
14
|
+
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
4
15
|
export class AudiobookshelfApiError extends Error {
|
|
5
16
|
status;
|
|
6
17
|
body;
|
|
@@ -11,6 +22,74 @@ export class AudiobookshelfApiError extends Error {
|
|
|
11
22
|
this.name = 'AudiobookshelfApiError';
|
|
12
23
|
}
|
|
13
24
|
}
|
|
25
|
+
/** Thrown when a response is larger than this server is willing to read. */
|
|
26
|
+
export class ResponseTooLargeError extends Error {
|
|
27
|
+
constructor(path, limit) {
|
|
28
|
+
super(`the Audiobookshelf response for ${path} exceeds the ` +
|
|
29
|
+
`${Math.round(limit / 1024 / 1024)} MB ceiling and was not read. ` +
|
|
30
|
+
'Narrow the request — most listing tools take limit and page, and ' +
|
|
31
|
+
'library_id restricts a server-wide listing to one library.');
|
|
32
|
+
this.name = 'ResponseTooLargeError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Thrown when a 200 carries something other than JSON.
|
|
37
|
+
*
|
|
38
|
+
* Returning the body instead would send an HTML login page into `listFrom`,
|
|
39
|
+
* which finds neither an array nor an envelope and answers `[]` — so
|
|
40
|
+
* `list_libraries` reports "you have no libraries" where the truth is that an
|
|
41
|
+
* SSO portal or a misconfigured reverse proxy answered instead of the API. A
|
|
42
|
+
* swallowed error replaced by a plausible wrong answer is worse than an error.
|
|
43
|
+
*/
|
|
44
|
+
export class UnexpectedContentTypeError extends Error {
|
|
45
|
+
constructor(path, contentType) {
|
|
46
|
+
super(`Audiobookshelf answered ${path} with "${contentType || 'no content type'}" ` +
|
|
47
|
+
'instead of JSON. A 200 that is not JSON usually means something in ' +
|
|
48
|
+
'front of the instance answered instead of the API — an SSO portal, a ' +
|
|
49
|
+
'captive proxy or a login page. Check AUDIOBOOKSHELF_URL.');
|
|
50
|
+
this.name = 'UnexpectedContentTypeError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reads a response body with a hard byte ceiling.
|
|
55
|
+
*
|
|
56
|
+
* Both halves matter: `content-length` catches an oversized answer before a
|
|
57
|
+
* single byte is read, and the streaming count catches a chunked response,
|
|
58
|
+
* which declares no length at all.
|
|
59
|
+
*/
|
|
60
|
+
async function readCapped(response, maxBytes, allowTruncation) {
|
|
61
|
+
const declared = Number(response.headers.get('content-length'));
|
|
62
|
+
if (Number.isFinite(declared) && declared > maxBytes && !allowTruncation) {
|
|
63
|
+
// Nothing has been read yet, so the body can simply be discarded.
|
|
64
|
+
await response.body?.cancel();
|
|
65
|
+
return { text: '', truncated: true };
|
|
66
|
+
}
|
|
67
|
+
const body = response.body;
|
|
68
|
+
if (!body)
|
|
69
|
+
return { text: '', truncated: false };
|
|
70
|
+
const reader = body.getReader();
|
|
71
|
+
const chunks = [];
|
|
72
|
+
let total = 0;
|
|
73
|
+
let truncated = false;
|
|
74
|
+
for (;;) {
|
|
75
|
+
const { done, value } = await reader.read();
|
|
76
|
+
if (done)
|
|
77
|
+
break;
|
|
78
|
+
if (value === undefined)
|
|
79
|
+
continue;
|
|
80
|
+
if (total + value.byteLength > maxBytes) {
|
|
81
|
+
// `maxBytes - total` is exactly the remaining budget, and the `>` above
|
|
82
|
+
// makes an exactly-maxBytes response legal rather than truncated.
|
|
83
|
+
chunks.push(value.subarray(0, maxBytes - total));
|
|
84
|
+
truncated = true;
|
|
85
|
+
await reader.cancel();
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
chunks.push(value);
|
|
89
|
+
total += value.byteLength;
|
|
90
|
+
}
|
|
91
|
+
return { text: Buffer.concat(chunks).toString('utf8'), truncated };
|
|
92
|
+
}
|
|
14
93
|
/** Minimal client for the Audiobookshelf REST API. */
|
|
15
94
|
export class AudiobookshelfApi {
|
|
16
95
|
config;
|
|
@@ -30,7 +109,7 @@ export class AudiobookshelfApi {
|
|
|
30
109
|
});
|
|
31
110
|
}
|
|
32
111
|
}
|
|
33
|
-
async request(method, path, body) {
|
|
112
|
+
async request(method, path, body, options = {}) {
|
|
34
113
|
// The credentials are only required here, not at startup, so the server can
|
|
35
114
|
// still be started and introspected without them.
|
|
36
115
|
const missing = missingConfigKeys(this.config);
|
|
@@ -62,32 +141,47 @@ export class AudiobookshelfApi {
|
|
|
62
141
|
dispatcher: this.insecureDispatcher,
|
|
63
142
|
})
|
|
64
143
|
: await fetch(url, init);
|
|
65
|
-
|
|
144
|
+
// An error body is only ever quoted back after `sanitizeErrorBody` cuts it
|
|
145
|
+
// to 2 000 characters, so truncating it costs nothing and keeps the status
|
|
146
|
+
// code — which is the diagnostic — instead of replacing it with a size
|
|
147
|
+
// complaint. A successful body cannot be truncated: half a JSON document is
|
|
148
|
+
// not a smaller answer.
|
|
149
|
+
const { text, truncated } = await readCapped(response, MAX_RESPONSE_BYTES, !response.ok);
|
|
66
150
|
if (!response.ok) {
|
|
67
151
|
throw new AudiobookshelfApiError(response.status, text, method, path);
|
|
68
152
|
}
|
|
153
|
+
if (truncated) {
|
|
154
|
+
throw new ResponseTooLargeError(path, MAX_RESPONSE_BYTES);
|
|
155
|
+
}
|
|
156
|
+
// A body-less success. Several routes answer 200 or 204 with nothing at
|
|
157
|
+
// all, and "nothing" has no content type to check.
|
|
158
|
+
if (text.length === 0)
|
|
159
|
+
return undefined;
|
|
160
|
+
// A route that is known to answer text. Its caller ignores the value.
|
|
161
|
+
if (options.text)
|
|
162
|
+
return text;
|
|
69
163
|
const contentType = response.headers.get('content-type') ?? '';
|
|
70
|
-
if (contentType.includes('application/json')) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
164
|
+
if (!contentType.includes('application/json')) {
|
|
165
|
+
throw new UnexpectedContentTypeError(path, contentType);
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
return JSON.parse(text);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
throw new UnexpectedContentTypeError(path, `${contentType} (unparseable)`);
|
|
77
172
|
}
|
|
78
|
-
return text;
|
|
79
173
|
}
|
|
80
|
-
get(path) {
|
|
81
|
-
return this.request('GET', path);
|
|
174
|
+
get(path, options) {
|
|
175
|
+
return this.request('GET', path, undefined, options);
|
|
82
176
|
}
|
|
83
|
-
post(path, body) {
|
|
84
|
-
return this.request('POST', path, body);
|
|
177
|
+
post(path, body, options) {
|
|
178
|
+
return this.request('POST', path, body, options);
|
|
85
179
|
}
|
|
86
|
-
patch(path, body) {
|
|
87
|
-
return this.request('PATCH', path, body);
|
|
180
|
+
patch(path, body, options) {
|
|
181
|
+
return this.request('PATCH', path, body, options);
|
|
88
182
|
}
|
|
89
|
-
delete(path) {
|
|
90
|
-
return this.request('DELETE', path);
|
|
183
|
+
delete(path, options) {
|
|
184
|
+
return this.request('DELETE', path, undefined, options);
|
|
91
185
|
}
|
|
92
186
|
}
|
|
93
187
|
/**
|
package/dist/api.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,EACL,KAAK,IAAI,WAAW,GAErB,MAAM,QAAQ,CAAC;AAEhB,OAAO,EACL,iBAAiB,EACjB,oBAAoB,GAErB,MAAM,aAAa,CAAC;AAErB,MAAM,kBAAkB,GAAG,MAAM,CAAC;AAElC,MAAM,OAAO,sBAAuB,SAAQ,KAAK;IAE7B;
|
|
1
|
+
{"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
|
package/dist/config.d.ts
CHANGED
|
@@ -7,7 +7,15 @@ export interface Config {
|
|
|
7
7
|
url: string | undefined;
|
|
8
8
|
apiKey: string | undefined;
|
|
9
9
|
insecureTls: boolean;
|
|
10
|
-
readOnly: boolean;
|
|
10
|
+
readOnly: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Whether a client that *can* show a dialog is asked before a guarded tool
|
|
13
|
+
* acts. `ELICITATION=false` turns the dialog off — the guard stays and falls
|
|
14
|
+
* back to the two-call token, so there is no setting in which a guarded call
|
|
15
|
+
* goes unannounced.
|
|
16
|
+
*/
|
|
17
|
+
elicitation: boolean;
|
|
18
|
+
/**
|
|
11
19
|
* Raw value of `AUDIOBOOKSHELF_ALLOW_TOOLS` — comma-separated tool names, `list_*`
|
|
12
20
|
* prefixes, or `essential`. Kept unparsed on purpose: this file is a mirror of
|
|
13
21
|
* the environment, and the names can only be checked against the tool
|
|
@@ -21,6 +29,20 @@ export interface Config {
|
|
|
21
29
|
export declare function missingConfigMessage(missing: string[]): string;
|
|
22
30
|
/** Names of the required environment variables that are unset in `config`. */
|
|
23
31
|
export declare function missingConfigKeys(config: Config): string[];
|
|
32
|
+
/**
|
|
33
|
+
* Reads `ELICITATION` — deliberately unprefixed, and deliberately fatal on
|
|
34
|
+
* anything it does not recognise.
|
|
35
|
+
*
|
|
36
|
+
* Unprefixed: environment variables are process-wide, so this is one switch for
|
|
37
|
+
* every server in the same environment. That is also its risk, which is why a
|
|
38
|
+
* server started with it off says so on its startup line.
|
|
39
|
+
*
|
|
40
|
+
* Fatal: this is the first variable of the family that defaults to *on*. The
|
|
41
|
+
* others fail open on a typo, which is the safe direction for them. Here a typo
|
|
42
|
+
* would leave the dialog running while the operator believes it is off — and an
|
|
43
|
+
* operator who believes that has no way to find out.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseElicitation(raw: string | undefined): boolean;
|
|
24
46
|
/**
|
|
25
47
|
* Reads the configuration from environment variables.
|
|
26
48
|
*
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { internalHostKind } from '
|
|
1
|
+
import { internalHostKind } from 'mcp-internal-hosts';
|
|
2
2
|
/** Shown when the configuration is incomplete — at startup and on every API call. */
|
|
3
3
|
export function missingConfigMessage(missing) {
|
|
4
4
|
return (`missing required environment variable(s): ${missing.join(', ')}\n` +
|
|
@@ -15,6 +15,29 @@ export function missingConfigKeys(config) {
|
|
|
15
15
|
!config.apiKey && 'AUDIOBOOKSHELF_API_KEY',
|
|
16
16
|
].filter((v) => Boolean(v));
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Reads `ELICITATION` — deliberately unprefixed, and deliberately fatal on
|
|
20
|
+
* anything it does not recognise.
|
|
21
|
+
*
|
|
22
|
+
* Unprefixed: environment variables are process-wide, so this is one switch for
|
|
23
|
+
* every server in the same environment. That is also its risk, which is why a
|
|
24
|
+
* server started with it off says so on its startup line.
|
|
25
|
+
*
|
|
26
|
+
* Fatal: this is the first variable of the family that defaults to *on*. The
|
|
27
|
+
* others fail open on a typo, which is the safe direction for them. Here a typo
|
|
28
|
+
* would leave the dialog running while the operator believes it is off — and an
|
|
29
|
+
* operator who believes that has no way to find out.
|
|
30
|
+
*/
|
|
31
|
+
export function parseElicitation(raw) {
|
|
32
|
+
const value = raw?.trim().toLowerCase();
|
|
33
|
+
if (value === undefined || value === '' || value === 'true')
|
|
34
|
+
return true;
|
|
35
|
+
if (value === 'false')
|
|
36
|
+
return false;
|
|
37
|
+
console.error(`audiobookshelf-mcp: ELICITATION must be "true" or "false" — got "${raw}". ` +
|
|
38
|
+
'Refusing to start rather than guess.');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
18
41
|
/**
|
|
19
42
|
* Reads the configuration from environment variables.
|
|
20
43
|
*
|
|
@@ -27,7 +50,12 @@ export function loadConfig(env = process.env) {
|
|
|
27
50
|
const url = env.AUDIOBOOKSHELF_URL;
|
|
28
51
|
const apiKey = env.AUDIOBOOKSHELF_API_KEY;
|
|
29
52
|
const insecureTls = env.AUDIOBOOKSHELF_INSECURE_TLS === 'true';
|
|
30
|
-
|
|
53
|
+
// Deliberately more forgiving than `AUDIOBOOKSHELF_INSECURE_TLS` above, and
|
|
54
|
+
// the asymmetry is the safety argument rather than an oversight: a misspelt
|
|
55
|
+
// value here fails *towards* the restriction, so `AUDIOBOOKSHELF_READ_ONLY=1`
|
|
56
|
+
// in a compose file must not silently register the write tools. The
|
|
57
|
+
// insecure-TLS switch fails the other way, so it keeps the exact-match rule.
|
|
58
|
+
const readOnly = /^(1|true|yes)$/i.test(env.AUDIOBOOKSHELF_READ_ONLY?.trim() ?? '');
|
|
31
59
|
const allowTools = env.AUDIOBOOKSHELF_ALLOW_TOOLS;
|
|
32
60
|
const denyTools = env.AUDIOBOOKSHELF_DENY_TOOLS;
|
|
33
61
|
// Don't keep the key in the environment for the process lifetime — it is
|
|
@@ -38,6 +66,9 @@ export function loadConfig(env = process.env) {
|
|
|
38
66
|
// key should still be sitting in the environment. Everything after this point
|
|
39
67
|
// reads the locals above, never `env` again.
|
|
40
68
|
delete env.AUDIOBOOKSHELF_API_KEY;
|
|
69
|
+
// After the delete, deliberately: this one can exit the process, and an exit
|
|
70
|
+
// above would leave the key in the environment for whatever runs next.
|
|
71
|
+
const elicitation = parseElicitation(env.ELICITATION);
|
|
41
72
|
const missing = [
|
|
42
73
|
!url && 'AUDIOBOOKSHELF_URL',
|
|
43
74
|
!apiKey && 'AUDIOBOOKSHELF_API_KEY',
|
|
@@ -51,6 +82,7 @@ export function loadConfig(env = process.env) {
|
|
|
51
82
|
apiKey,
|
|
52
83
|
insecureTls,
|
|
53
84
|
readOnly,
|
|
85
|
+
elicitation,
|
|
54
86
|
allowTools,
|
|
55
87
|
denyTools,
|
|
56
88
|
};
|
|
@@ -81,10 +113,18 @@ export function loadConfig(env = process.env) {
|
|
|
81
113
|
'the API key will be sent unencrypted. Use https:// instead.');
|
|
82
114
|
}
|
|
83
115
|
return {
|
|
84
|
-
|
|
116
|
+
// Built from the parsed URL, not from the raw string. `new URL()` accepts
|
|
117
|
+
// more than a base URL may contain, and `fetch` then silently drops the
|
|
118
|
+
// extra: `https://abs.example.com/#dev` survives validation, loses
|
|
119
|
+
// everything from the `#` onwards, and every request goes to `/` — where
|
|
120
|
+
// the web UI answers 200 with HTML. Before the content-type check in
|
|
121
|
+
// `api.ts` that showed up as empty libraries rather than as an error.
|
|
122
|
+
// A query string goes the same way, one `?` earlier.
|
|
123
|
+
url: `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, ''),
|
|
85
124
|
apiKey,
|
|
86
125
|
insecureTls,
|
|
87
126
|
readOnly,
|
|
127
|
+
elicitation,
|
|
88
128
|
allowTools,
|
|
89
129
|
denyTools,
|
|
90
130
|
};
|