audiobookshelf-mcp 0.3.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 CHANGED
@@ -1,14 +1,19 @@
1
1
  # audiobookshelf-mcp
2
2
 
3
+ <!-- badges: start -->
4
+
3
5
  [![CI](https://img.shields.io/github/actions/workflow/status/ni-c/audiobookshelf-mcp/ci.yml?branch=main&label=CI)](https://github.com/ni-c/audiobookshelf-mcp/actions/workflows/ci.yml)
6
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/ni-c/audiobookshelf-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/ni-c/audiobookshelf-mcp)
7
+ <a href="https://socket.dev/npm/package/audiobookshelf-mcp"><img src="https://socket.dev/api/badge/npm/package/audiobookshelf-mcp" alt="Socket supply-chain report" height="20"></a>
8
+ [![Glama score](https://glama.ai/mcp/servers/ni-c/audiobookshelf-mcp/badges/score.svg)](https://glama.ai/mcp/servers/ni-c/audiobookshelf-mcp)
9
+ <br>
4
10
  [![npm version](https://img.shields.io/npm/v/audiobookshelf-mcp)](https://www.npmjs.com/package/audiobookshelf-mcp)
5
- [![npm downloads](https://img.shields.io/npm/dm/audiobookshelf-mcp)](https://www.npmjs.com/package/audiobookshelf-mcp)
6
- [![node](https://img.shields.io/node/v/audiobookshelf-mcp)](https://nodejs.org)
7
- [![license](https://img.shields.io/npm/l/audiobookshelf-mcp)](LICENSE)
8
- [![container](https://img.shields.io/badge/ghcr.io-ni--c%2Faudiobookshelf--mcp-blue)](https://github.com/ni-c/audiobookshelf-mcp/pkgs/container/audiobookshelf-mcp)
9
- [![docs](https://img.shields.io/badge/docs-audiobookshelf--mcp.ni--c.de-informational)](https://audiobookshelf-mcp.ni-c.de)
10
- [![HTTP • via mcp-hub](https://img.shields.io/badge/HTTP-via%20mcp--hub-6f42c1)](https://mcp-hub.ni-c.de)
11
+ [![container image](https://img.shields.io/badge/ghcr.io-ni--c%2Faudiobookshelf--mcp-4f46e5?logo=docker&logoColor=white)](https://github.com/ni-c/audiobookshelf-mcp/pkgs/container/audiobookshelf-mcp)
12
+ [![HTTP via mcp-hub](https://img.shields.io/badge/HTTP-via%20mcp--hub-4f46e5?logo=modelcontextprotocol&logoColor=white)](https://mcp-hub.ni-c.de)
13
+ <br>
14
+ [![docs](https://img.shields.io/badge/docs-audiobookshelf--mcp.ni--c.de-4f46e5?logo=readthedocs&logoColor=white)](https://audiobookshelf-mcp.ni-c.de)
11
15
  [![sponsor](https://img.shields.io/badge/sponsor-ni--c-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/ni-c)
16
+ <!-- badges: end -->
12
17
 
13
18
  A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for
14
19
  [Audiobookshelf](https://www.audiobookshelf.org/), the self-hosted audiobook and
@@ -200,8 +205,10 @@ answer with the fields as well.
200
205
  The tools that report library metadata carry `untrusted: true` and
201
206
  `source: "audiobookshelf"` as fields: book descriptions pulled from metadata
202
207
  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.
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.
205
212
 
206
213
  The documents are described as open objects with the top-level keys this server
207
214
  builds. `detail: "full"` hands the API record back whole, so the same tool
package/dist/api.js CHANGED
@@ -1,6 +1,15 @@
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;
4
13
  /**
5
14
  * Ceiling on a single upstream response.
6
15
  *
@@ -132,6 +141,18 @@ export class AudiobookshelfApi {
132
141
  headers['Content-Type'] = 'application/json';
133
142
  init.body = JSON.stringify(body);
134
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
+ }
135
156
  const url = `${this.baseUrl}${path}`;
136
157
  // The insecure dispatcher requires undici's own fetch; the default path uses
137
158
  // the (stubbable) global fetch so tests can intercept it.
@@ -141,12 +162,13 @@ export class AudiobookshelfApi {
141
162
  dispatcher: this.insecureDispatcher,
142
163
  })
143
164
  : await fetch(url, init);
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);
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);
150
172
  if (!response.ok) {
151
173
  throw new AudiobookshelfApiError(response.status, text, method, path);
152
174
  }
@@ -162,13 +184,13 @@ export class AudiobookshelfApi {
162
184
  return text;
163
185
  const contentType = response.headers.get('content-type') ?? '';
164
186
  if (!contentType.includes('application/json')) {
165
- throw new UnexpectedContentTypeError(path, contentType);
187
+ throw new UnexpectedContentTypeError(path, quoted(contentType));
166
188
  }
167
189
  try {
168
190
  return JSON.parse(text);
169
191
  }
170
192
  catch {
171
- throw new UnexpectedContentTypeError(path, `${contentType} (unparseable)`);
193
+ throw new UnexpectedContentTypeError(path, `${quoted(contentType)} (unparseable)`);
172
194
  }
173
195
  }
174
196
  get(path, options) {
@@ -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
+ };
package/dist/clean.js ADDED
@@ -0,0 +1,239 @@
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
+ function isControl(code) {
36
+ if (code < 0x20)
37
+ return code !== 0x09 && code !== 0x0a && code !== 0x0d;
38
+ return code >= 0x7f && code <= 0x9f;
39
+ }
40
+ /** Whether a string carries anything {@link cleanText} would remove. */
41
+ export function hasControl(value) {
42
+ for (let index = 0; index < value.length; index++) {
43
+ if (isControl(value.charCodeAt(index)))
44
+ return true;
45
+ }
46
+ return false;
47
+ }
48
+ /**
49
+ * Strips control characters and repairs lone surrogates.
50
+ *
51
+ * Linear, and cheap on the common case: a string with nothing to remove is
52
+ * returned as it came, after a well-formedness check that costs one pass.
53
+ */
54
+ export function cleanText(value) {
55
+ let out;
56
+ let start = 0;
57
+ for (let index = 0; index < value.length; index++) {
58
+ if (isControl(value.charCodeAt(index))) {
59
+ out = (out ?? '') + value.slice(start, index);
60
+ start = index + 1;
61
+ }
62
+ }
63
+ const stripped = out === undefined ? value : out + value.slice(start);
64
+ return stripped.isWellFormed() ? stripped : stripped.toWellFormed();
65
+ }
66
+ /**
67
+ * Removes credentials from a URL.
68
+ *
69
+ * The pattern stops at the *last* `@` before the path — `[^/?#]*@` — so
70
+ * `https://a@b@host/` loses both, and a path or query that merely contains an
71
+ * `@` is left alone.
72
+ */
73
+ export function redactUrl(url) {
74
+ return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#]*@/i, '$1***@');
75
+ }
76
+ /**
77
+ * Whether a string is URL-shaped enough that {@link redactUrl} should see it.
78
+ *
79
+ * Deliberately narrow: only a value that begins with a scheme and `//`, which
80
+ * is what a `feedUrl`, an `imageUrl` or an enclosure address looks like. A
81
+ * description that merely mentions an address is prose and stays as written.
82
+ */
83
+ function looksLikeUrl(value) {
84
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
85
+ }
86
+ /**
87
+ * {@link cleanText} over a whole structure, with URL redaction on the way.
88
+ *
89
+ * Rebuilds every object with `Object.fromEntries`, so a key of `__proto__` —
90
+ * an own property after `JSON.parse`, and legal JSON from any backend — stays
91
+ * an own property of the copy instead of becoming its prototype. Keys are
92
+ * cleaned as well as values: a key is text a model reads too.
93
+ *
94
+ * Numbers, booleans and null pass through. `undefined` and functions cannot
95
+ * come out of JSON; they are dropped from objects, where `JSON.stringify`
96
+ * would drop them anyway, and written as `null` in arrays, which is also what
97
+ * it would do — so the two channels cannot disagree about them.
98
+ */
99
+ export function cleanValue(value) {
100
+ if (typeof value === 'string') {
101
+ const clean = cleanText(value);
102
+ return looksLikeUrl(clean) ? redactUrl(clean) : clean;
103
+ }
104
+ if (Array.isArray(value)) {
105
+ return value.map((entry) => entry === undefined || typeof entry === 'function'
106
+ ? null
107
+ : cleanValue(entry));
108
+ }
109
+ if (typeof value === 'object' && value !== null) {
110
+ return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => entry === undefined || typeof entry === 'function'
111
+ ? []
112
+ : [[cleanText(key), cleanValue(entry)]]));
113
+ }
114
+ return value;
115
+ }
116
+ /** How much of an upstream error body may be quoted back. */
117
+ const MAX_UPSTREAM_TEXT = 2000;
118
+ /**
119
+ * Text written by whatever answered a request, made safe to quote.
120
+ *
121
+ * Audiobookshelf's error bodies are short and worth reading — "Invalid
122
+ * playlist items. Length mismatch" is the whole diagnosis. But the thing that
123
+ * answers is not always Audiobookshelf: a reverse proxy, an SSO portal or an
124
+ * outbound filter writes its own body, and under `AUDIOBOOKSHELF_INSECURE_TLS`
125
+ * so can anything that can reach the address. So the text is stripped, cut and
126
+ * labelled as what it is.
127
+ */
128
+ export function upstreamText(text, max = MAX_UPSTREAM_TEXT) {
129
+ const trimmed = cleanText(text).trim();
130
+ if (trimmed.length === 0)
131
+ return '';
132
+ // Anything markup-shaped: a reverse proxy's error page or a WAF block page.
133
+ // The check is deliberately loose — an XML declaration, a leading comment or
134
+ // a doctype followed by a newline are all the same thing here.
135
+ if (/^(<!doctype|<html[\s>]|<\?xml|<!--)/i.test(trimmed)) {
136
+ return '(HTML error page omitted)';
137
+ }
138
+ const cut = trimmed.length > max
139
+ ? `${trimmed.slice(0, max).toWellFormed()}… (truncated)`
140
+ : trimmed;
141
+ return `(untrusted text from the instance): ${cut}`;
142
+ }
143
+ /**
144
+ * A value shortened for a sentence.
145
+ *
146
+ * For the messages that have to name what was rejected — a media type that is
147
+ * not "book", a content type that is not JSON — without letting a hundred
148
+ * kilobytes of the instance's choosing into the model's context.
149
+ */
150
+ export function quoted(value, max = 80) {
151
+ const clean = cleanText(value);
152
+ return clean.length > max
153
+ ? `${clean.slice(0, max).toWellFormed()}… (${clean.length - max} more characters)`
154
+ : clean;
155
+ }
156
+ /**
157
+ * Refuses a header value the HTTP layer would refuse, without quoting it.
158
+ *
159
+ * undici's own refusal is `Headers.append: "<value>" is an invalid header
160
+ * value.` — the whole value, in a `TypeError` that this server turns into a
161
+ * tool result. The one header this server builds from a secret is
162
+ * `Authorization`, so an API key with a line break in the middle of it — a
163
+ * wrapped paste — would arrive in the model's context by way of an error
164
+ * message. Refusing first means the runtime never gets to quote one.
165
+ *
166
+ * The message names the header and where the offending character sits, which
167
+ * is what someone fixing a pasted credential needs, and nothing else.
168
+ */
169
+ export function assertHeaderValue(name, value) {
170
+ for (let index = 0; index < value.length; index++) {
171
+ const code = value.charCodeAt(index);
172
+ // Visible ASCII, plus space and tab, is what a header value may hold.
173
+ if (code === 0x09 || (code >= 0x20 && code <= 0x7e))
174
+ continue;
175
+ throw new Error(`the ${name} header cannot be sent: the value holds a character that is ` +
176
+ `not allowed in an HTTP header at position ${index} of ${value.length}. ` +
177
+ 'For Authorization this is the API key — check ' +
178
+ 'AUDIOBOOKSHELF_API_KEY for a line break or a stray character from ' +
179
+ 'the paste. The value itself is not shown.');
180
+ }
181
+ }
182
+ /**
183
+ * Field names whose value is a credential, matched on the normalised suffix.
184
+ *
185
+ * Suffix rather than exact match: Audiobookshelf's user object calls the
186
+ * password hash `pash` and the access token `token`, but a backend is free to
187
+ * answer `git-password` or `oauth_client_secret` in any pass-through record,
188
+ * and an exact list has to be right about spellings nobody controls. `key` is
189
+ * deliberately absent — it would take every `*_key` identifier with it.
190
+ */
191
+ const CREDENTIAL_SUFFIXES = [
192
+ 'password',
193
+ 'passwd',
194
+ 'passphrase',
195
+ 'pash',
196
+ 'secret',
197
+ 'token',
198
+ 'apikey',
199
+ 'privatekey',
200
+ ];
201
+ /** Whether a field of this name holds a credential. */
202
+ export function isCredentialKey(key) {
203
+ const normalised = key.toLowerCase().replaceAll(/[_-]/g, '');
204
+ return CREDENTIAL_SUFFIXES.some((suffix) => normalised.endsWith(suffix));
205
+ }
206
+ const REDACTED = '(removed by audiobookshelf-mcp: this field is a credential)';
207
+ /**
208
+ * Replaces credential-shaped fields anywhere in a record the API returned.
209
+ *
210
+ * The reason this exists at all is `GET /api/me`. It answers with
211
+ * `User.toOldJSONForBrowser()`, and `MeController.getCurrentUser` calls it
212
+ * without `hideRootToken` — so the document carries `token`, the account's old
213
+ * non-expiring access token, for the root user included. `detail: "full"`
214
+ * hands the raw record on, which put a credential that outlives this process
215
+ * into a model's context and into whatever that model's operator logs.
216
+ *
217
+ * Written as a walk over every pass-through record rather than as a `delete`
218
+ * on that one field: the projection is not the API, and the next release of
219
+ * either is free to add a second one.
220
+ */
221
+ export function redactCredentials(value, report = { removed: [] }, path = '') {
222
+ if (Array.isArray(value)) {
223
+ const items = value.map((entry, index) => redactCredentials(entry, report, `${path}[${index}]`).value);
224
+ return { value: items, report };
225
+ }
226
+ if (typeof value === 'object' && value !== null) {
227
+ const entries = Object.entries(value).map(([key, entry]) => {
228
+ const here = path === '' ? key : `${path}.${key}`;
229
+ if (isCredentialKey(key)) {
230
+ report.removed.push(here);
231
+ return [key, REDACTED];
232
+ }
233
+ return [key, redactCredentials(entry, report, here).value];
234
+ });
235
+ return { value: Object.fromEntries(entries), report };
236
+ }
237
+ return { value, report };
238
+ }
239
+ //# sourceMappingURL=clean.js.map
package/dist/config.js CHANGED
@@ -34,10 +34,43 @@ export function parseElicitation(raw) {
34
34
  return true;
35
35
  if (value === 'false')
36
36
  return false;
37
- console.error(`audiobookshelf-mcp: ELICITATION must be "true" or "false" got "${raw}". ` +
38
- 'Refusing to start rather than guess.');
37
+ // Described, not quoted. ELICITATION is unprefixed and sits in the same
38
+ // block of a compose file as AUDIOBOOKSHELF_API_KEY, so the value that lands
39
+ // here wrong is a candidate for being the credential from the line above —
40
+ // and this message goes to stderr, which is the MCP client's log.
41
+ console.error('audiobookshelf-mcp: ELICITATION must be "true" or "false" — got ' +
42
+ `${describeValue(raw)}. Refusing to start rather than guess.`);
39
43
  process.exit(1);
40
44
  }
45
+ /**
46
+ * A configuration value named by shape and length rather than quoted.
47
+ *
48
+ * Every diagnostic in this file fires precisely when a variable does not hold
49
+ * what was expected, which is exactly the state a credential pasted into the
50
+ * wrong line produces. Only a value that already looks like one of the words
51
+ * being asked for is safe to repeat back.
52
+ */
53
+ function describeValue(raw) {
54
+ if (raw === undefined)
55
+ return 'nothing';
56
+ const trimmed = raw.trim();
57
+ if (trimmed === '')
58
+ return 'an empty value';
59
+ if (/^[A-Za-z0-9_.-]{1,20}$/.test(trimmed))
60
+ return `"${trimmed}"`;
61
+ return `a ${trimmed.length}-character value`;
62
+ }
63
+ /**
64
+ * Shape of an Audiobookshelf API key, checked at startup.
65
+ *
66
+ * Not a format claim — the key is a JWT today and this server has no business
67
+ * pinning that. It is the one property the HTTP layer requires: printable
68
+ * ASCII, no line breaks. A key pasted with a wrapped newline inside it reaches
69
+ * undici, whose refusal quotes the value in full, and that TypeError becomes a
70
+ * tool result. Refusing here means the process never starts with a credential
71
+ * it cannot send.
72
+ */
73
+ const API_KEY_SHAPE = /^[!-~]{8,4096}$/;
41
74
  /**
42
75
  * Reads the configuration from environment variables.
43
76
  *
@@ -48,7 +81,10 @@ export function parseElicitation(raw) {
48
81
  */
49
82
  export function loadConfig(env = process.env) {
50
83
  const url = env.AUDIOBOOKSHELF_URL;
51
- const apiKey = env.AUDIOBOOKSHELF_API_KEY;
84
+ // Trimmed before anything else: `AUDIOBOOKSHELF_API_KEY=$(cat key)` leaves a
85
+ // trailing newline, which the Headers constructor strips and an inner one it
86
+ // does not.
87
+ const apiKey = env.AUDIOBOOKSHELF_API_KEY?.trim();
52
88
  const insecureTls = env.AUDIOBOOKSHELF_INSECURE_TLS === 'true';
53
89
  // Deliberately more forgiving than `AUDIOBOOKSHELF_INSECURE_TLS` above, and
54
90
  // the asymmetry is the safety argument rather than an oversight: a misspelt
@@ -66,8 +102,19 @@ export function loadConfig(env = process.env) {
66
102
  // key should still be sitting in the environment. Everything after this point
67
103
  // reads the locals above, never `env` again.
68
104
  delete env.AUDIOBOOKSHELF_API_KEY;
69
- // After the delete, deliberately: this one can exit the process, and an exit
105
+ // After the delete, deliberately: these can exit the process, and an exit
70
106
  // above would leave the key in the environment for whatever runs next.
107
+ if (apiKey !== undefined && apiKey !== '' && !API_KEY_SHAPE.test(apiKey)) {
108
+ // Never the value, and never the position of the offending character
109
+ // either — the length is what tells a wrapped paste from a truncated
110
+ // one, and that is all somebody needs to look at the right line.
111
+ console.error('audiobookshelf-mcp: AUDIOBOOKSHELF_API_KEY does not have the shape of ' +
112
+ 'an API key: it must be 8 to 4096 printable ASCII characters with no ' +
113
+ 'spaces, line breaks or control characters. The value read was ' +
114
+ `${apiKey.length} characters long. It is not shown. Create the key ` +
115
+ 'under Settings \u2192 Users \u2192 API Keys and paste it as one line.');
116
+ process.exit(1);
117
+ }
71
118
  const elicitation = parseElicitation(env.ELICITATION);
72
119
  const missing = [
73
120
  !url && 'AUDIOBOOKSHELF_URL',
@@ -100,7 +147,13 @@ export function loadConfig(env = process.env) {
100
147
  process.exit(1);
101
148
  }
102
149
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
103
- console.error(`audiobookshelf-mcp: AUDIOBOOKSHELF_URL must use http:// or https:// (got ${parsed.protocol})`);
150
+ // The scheme is not printed. A 56-character hexadecimal key with a colon
151
+ // after it is a valid URL whose scheme is the key, so this branch is one
152
+ // of the two a pasted credential reaches — and the other one below
153
+ // already refuses to echo.
154
+ console.error('audiobookshelf-mcp: AUDIOBOOKSHELF_URL must use http:// or https:// ' +
155
+ `— the value read uses neither (${describeValue(parsed.protocol.replace(/:$/, ''))} ` +
156
+ 'as its scheme).');
104
157
  process.exit(1);
105
158
  }
106
159
  // Credentials embedded in the URL would end up in logs and error messages.
@@ -120,7 +173,7 @@ export function loadConfig(env = process.env) {
120
173
  // the web UI answers 200 with HTML. Before the content-type check in
121
174
  // `api.ts` that showed up as empty libraries rather than as an error.
122
175
  // A query string goes the same way, one `?` earlier.
123
- url: `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, ''),
176
+ url: withoutTrailingSlashes(`${parsed.origin}${parsed.pathname}`),
124
177
  apiKey,
125
178
  insecureTls,
126
179
  readOnly,
@@ -129,6 +182,22 @@ export function loadConfig(env = process.env) {
129
182
  denyTools,
130
183
  };
131
184
  }
185
+ /**
186
+ * Drops the trailing slashes of the base URL, in one pass.
187
+ *
188
+ * `replace(/\/+$/, '')` looks like the obvious way and is quadratic: the
189
+ * pattern is tried from every position of the run, and consumes the run each
190
+ * time. An operator URL ending in eighty thousand slashes followed by one more
191
+ * character cost 1.7 seconds at startup. Walking an index backwards and
192
+ * slicing once is linear, and this is a base URL — the length is whatever was
193
+ * pasted.
194
+ */
195
+ function withoutTrailingSlashes(value) {
196
+ let end = value.length;
197
+ while (end > 0 && value.charCodeAt(end - 1) === 0x2f)
198
+ end--;
199
+ return end === value.length ? value : value.slice(0, end);
200
+ }
132
201
  function isLoopbackHost(hostname) {
133
202
  // The shared classifier, so every spelling of a loopback address is
134
203
  // recognised — including http://[::ffff:127.0.0.1] and 'localhost.' with its
package/dist/result.d.ts CHANGED
@@ -14,8 +14,8 @@ export declare const MAX_RESULT_BYTES = 100000;
14
14
  export declare function textResult(text: string): CallToolResult;
15
15
  export declare function errorResult(text: string): CallToolResult;
16
16
  /**
17
- * Serializes a result inside {@link MAX_RESULT_BYTES}, dropping whole entries
18
- * rather than characters.
17
+ * Fits a result inside {@link MAX_RESULT_BYTES}, dropping whole entries rather
18
+ * than characters.
19
19
  *
20
20
  * Whole entries, never a slice of the serialized JSON: a truncated document is
21
21
  * not a smaller answer, it is an unparseable one. The `truncated` block comes
@@ -24,15 +24,10 @@ export declare function errorResult(text: string): CallToolResult;
24
24
  *
25
25
  * It sits in `jsonResult` and `untrustedJsonResult` rather than in each tool,
26
26
  * so `detail: "full"` — which switches the compact projections off — is covered
27
- * by the same ceiling.
28
- */
29
- export declare function budgetedJson(data: unknown): string;
30
- /**
31
- * The same, as a value rather than as text.
32
- *
33
- * Every tool declares an `outputSchema` and answers with `structuredContent`
34
- * beside the text block, and the two have to carry the same thing — so the
35
- * shrinking happens on the object and the serialization is derived from it.
27
+ * by the same ceiling. Every tool declares an `outputSchema` and answers with
28
+ * `structuredContent` beside the text block, and the two have to carry the
29
+ * same thing, so the shrinking happens on the object and the serialization is
30
+ * derived from it.
36
31
  */
37
32
  export declare function budget(data: unknown): Record<string, unknown>;
38
33
  /** Raised by {@link budget}; `run` turns it into an error result. */