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.
Files changed (54) hide show
  1. package/README.md +153 -26
  2. package/dist/api.d.ts +35 -5
  3. package/dist/api.js +112 -18
  4. package/dist/api.js.map +1 -1
  5. package/dist/config.d.ts +23 -1
  6. package/dist/config.js +43 -3
  7. package/dist/config.js.map +1 -1
  8. package/dist/filters.d.ts +3 -3
  9. package/dist/index.js +32 -5
  10. package/dist/index.js.map +1 -1
  11. package/dist/output-schema.d.ts +55 -0
  12. package/dist/output-schema.js +57 -0
  13. package/dist/output-schema.js.map +1 -0
  14. package/dist/result.d.ts +53 -5
  15. package/dist/result.js +182 -10
  16. package/dist/result.js.map +1 -1
  17. package/dist/server.d.ts +1 -1
  18. package/dist/server.js +36 -11
  19. package/dist/server.js.map +1 -1
  20. package/dist/shape.d.ts +1 -1
  21. package/dist/shape.js +27 -2
  22. package/dist/shape.js.map +1 -1
  23. package/dist/tools/annotations.d.ts +32 -0
  24. package/dist/tools/annotations.js +33 -0
  25. package/dist/tools/annotations.js.map +1 -0
  26. package/dist/tools/catalogue.d.ts +2 -2
  27. package/dist/tools/collections.d.ts +3 -3
  28. package/dist/tools/collections.js +184 -47
  29. package/dist/tools/collections.js.map +1 -1
  30. package/dist/tools/items.d.ts +1 -1
  31. package/dist/tools/items.js +19 -13
  32. package/dist/tools/items.js.map +1 -1
  33. package/dist/tools/libraries.d.ts +1 -1
  34. package/dist/tools/libraries.js +74 -41
  35. package/dist/tools/libraries.js.map +1 -1
  36. package/dist/tools/me.d.ts +1 -1
  37. package/dist/tools/me.js +48 -29
  38. package/dist/tools/me.js.map +1 -1
  39. package/dist/tools/playlists.d.ts +3 -3
  40. package/dist/tools/playlists.js +172 -42
  41. package/dist/tools/playlists.js.map +1 -1
  42. package/dist/tools/progress.d.ts +4 -4
  43. package/dist/tools/progress.js +121 -34
  44. package/dist/tools/progress.js.map +1 -1
  45. package/package.json +16 -11
  46. package/dist/confirm.d.ts +0 -36
  47. package/dist/confirm.js +0 -72
  48. package/dist/confirm.js.map +0 -1
  49. package/dist/hosts.d.ts +0 -22
  50. package/dist/hosts.js +0 -144
  51. package/dist/hosts.js.map +0 -1
  52. package/dist/tool-filter.d.ts +0 -45
  53. package/dist/tool-filter.js +0 -171
  54. package/dist/tool-filter.js.map +0 -1
package/README.md CHANGED
@@ -7,6 +7,7 @@
7
7
  [![license](https://img.shields.io/npm/l/audiobookshelf-mcp)](LICENSE)
8
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
9
  [![docs](https://img.shields.io/badge/docs-audiobookshelf--mcp.ni--c.de-informational)](https://audiobookshelf-mcp.ni-c.de)
10
+ [![HTTP • via mcp-hub](https://img.shields.io/badge/HTTP-via%20mcp--hub-6f42c1)](https://mcp-hub.ni-c.de)
10
11
  [![sponsor](https://img.shields.io/badge/sponsor-ni--c-ea4aaa?logo=githubsponsors&logoColor=white)](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
- ## Install
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 (`claude_desktop_config.json`):
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 (`~/.codex/config.toml`):
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
- Container (multi-arch, with SBOM and build provenance):
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 | 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 |
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. List tools are capped at 100 entries per call
187
- and say how to page on when more match.
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
- - **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.
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 only ids.
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 # eslint + prettier --check
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
- const text = await response.text();
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
- try {
72
- return JSON.parse(text);
73
- }
74
- catch {
75
- return text;
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":"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"}
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 './hosts.js';
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
- const readOnly = env.AUDIOBOOKSHELF_READ_ONLY === 'true';
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
- url: url.replace(/\/+$/, ''),
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
  };