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 +15 -8
- package/dist/api.js +30 -8
- package/dist/clean.d.ts +123 -0
- package/dist/clean.js +239 -0
- package/dist/config.js +75 -6
- package/dist/result.d.ts +6 -11
- package/dist/result.js +200 -81
- package/dist/schema.d.ts +25 -0
- package/dist/schema.js +31 -8
- package/dist/server.js +42 -3
- package/dist/shape.d.ts +28 -0
- package/dist/shape.js +52 -7
- package/dist/tools/collections.js +61 -30
- package/dist/tools/items.js +32 -32
- package/dist/tools/libraries.js +72 -48
- package/dist/tools/me.js +41 -25
- package/dist/tools/playlists.d.ts +10 -1
- package/dist/tools/playlists.js +119 -37
- package/dist/tools/progress.js +28 -15
- package/package.json +9 -9
- package/dist/api.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/filters.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/output-schema.js.map +0 -1
- package/dist/result.js.map +0 -1
- package/dist/schema.js.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/shape.js.map +0 -1
- package/dist/tools/annotations.js.map +0 -1
- package/dist/tools/catalogue.js.map +0 -1
- package/dist/tools/collections.js.map +0 -1
- package/dist/tools/items.js.map +0 -1
- package/dist/tools/libraries.js.map +0 -1
- package/dist/tools/me.js.map +0 -1
- package/dist/tools/playlists.js.map +0 -1
- package/dist/tools/progress.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
# audiobookshelf-mcp
|
|
2
2
|
|
|
3
|
+
<!-- badges: start -->
|
|
4
|
+
|
|
3
5
|
[](https://github.com/ni-c/audiobookshelf-mcp/actions/workflows/ci.yml)
|
|
6
|
+
[](https://scorecard.dev/viewer/?uri=github.com/ni-c/audiobookshelf-mcp)
|
|
7
|
+
<a href="https://socket.dev/npm/package/audiobookshelf-mcp"><img src="https://socket.dev/api/badge/npm/package/audiobookshelf-mcp" alt="Socket supply-chain report" height="20"></a>
|
|
8
|
+
[](https://glama.ai/mcp/servers/ni-c/audiobookshelf-mcp)
|
|
9
|
+
<br>
|
|
4
10
|
[](https://www.npmjs.com/package/audiobookshelf-mcp)
|
|
5
|
-
[](https://audiobookshelf-mcp.ni-c.de)
|
|
10
|
-
[](https://mcp-hub.ni-c.de)
|
|
11
|
+
[](https://github.com/ni-c/audiobookshelf-mcp/pkgs/container/audiobookshelf-mcp)
|
|
12
|
+
[](https://mcp-hub.ni-c.de)
|
|
13
|
+
<br>
|
|
14
|
+
[](https://audiobookshelf-mcp.ni-c.de)
|
|
11
15
|
[](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
|
|
204
|
-
|
|
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
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
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) {
|
package/dist/clean.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is done to text on its way out of this server.
|
|
3
|
+
*
|
|
4
|
+
* Every string an Audiobookshelf answer carries was written by somebody else:
|
|
5
|
+
* a title or a narrator's name from the file's own tags, a description from
|
|
6
|
+
* whichever metadata provider the instance queries, an episode summary from a
|
|
7
|
+
* podcast feed, a collection name from whoever shares the server. All of it
|
|
8
|
+
* goes into a model's context. Three things happen here, in one place, so no
|
|
9
|
+
* field is the one a sweep missed:
|
|
10
|
+
*
|
|
11
|
+
* - **C0 and C1 control characters and DEL are removed**, except tab, line feed
|
|
12
|
+
* and carriage return. A terminal escape in a book title repaints the log of
|
|
13
|
+
* whoever reads the tool result; a NUL ends the string early for whatever
|
|
14
|
+
* parses it next. Nothing in this API means anything by them.
|
|
15
|
+
* - **Lone surrogates are repaired.** `"\ud800"` is legal JSON and parses to
|
|
16
|
+
* half a character. `JSON.stringify` writes it back as an escape, so the wire
|
|
17
|
+
* stays valid — and a Python client encoding the text to UTF-8 then raises
|
|
18
|
+
* `UnicodeEncodeError: surrogates not allowed`. `toWellFormed()` replaces the
|
|
19
|
+
* half with U+FFFD, and it runs after every cut, because a cut can split a
|
|
20
|
+
* pair.
|
|
21
|
+
* - **Credentials in URLs are redacted.** A podcast `feedUrl` is the one field
|
|
22
|
+
* here that routinely carries them: a private feed is published as
|
|
23
|
+
* `https://user:token@feeds.example.com/…`, Audiobookshelf stores it as
|
|
24
|
+
* given, and `get_library_item` hands it back.
|
|
25
|
+
*
|
|
26
|
+
* Format characters (bidi marks, joiners, zero-width) are kept. They are
|
|
27
|
+
* content in a title written in Arabic, Hebrew or Hindi, and this server's
|
|
28
|
+
* results are already framed as untrusted where it matters.
|
|
29
|
+
*
|
|
30
|
+
* The character classes are decided by code point in a loop rather than spelled
|
|
31
|
+
* as a regular expression: the editing tools of this family turn a backslash-u
|
|
32
|
+
* escape in a source line into the raw byte, and a raw escape character in this
|
|
33
|
+
* file is exactly what the file exists to keep out of a result.
|
|
34
|
+
*/
|
|
35
|
+
/** Whether a string carries anything {@link cleanText} would remove. */
|
|
36
|
+
export declare function hasControl(value: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Strips control characters and repairs lone surrogates.
|
|
39
|
+
*
|
|
40
|
+
* Linear, and cheap on the common case: a string with nothing to remove is
|
|
41
|
+
* returned as it came, after a well-formedness check that costs one pass.
|
|
42
|
+
*/
|
|
43
|
+
export declare function cleanText(value: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Removes credentials from a URL.
|
|
46
|
+
*
|
|
47
|
+
* The pattern stops at the *last* `@` before the path — `[^/?#]*@` — so
|
|
48
|
+
* `https://a@b@host/` loses both, and a path or query that merely contains an
|
|
49
|
+
* `@` is left alone.
|
|
50
|
+
*/
|
|
51
|
+
export declare function redactUrl(url: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* {@link cleanText} over a whole structure, with URL redaction on the way.
|
|
54
|
+
*
|
|
55
|
+
* Rebuilds every object with `Object.fromEntries`, so a key of `__proto__` —
|
|
56
|
+
* an own property after `JSON.parse`, and legal JSON from any backend — stays
|
|
57
|
+
* an own property of the copy instead of becoming its prototype. Keys are
|
|
58
|
+
* cleaned as well as values: a key is text a model reads too.
|
|
59
|
+
*
|
|
60
|
+
* Numbers, booleans and null pass through. `undefined` and functions cannot
|
|
61
|
+
* come out of JSON; they are dropped from objects, where `JSON.stringify`
|
|
62
|
+
* would drop them anyway, and written as `null` in arrays, which is also what
|
|
63
|
+
* it would do — so the two channels cannot disagree about them.
|
|
64
|
+
*/
|
|
65
|
+
export declare function cleanValue(value: unknown): unknown;
|
|
66
|
+
/**
|
|
67
|
+
* Text written by whatever answered a request, made safe to quote.
|
|
68
|
+
*
|
|
69
|
+
* Audiobookshelf's error bodies are short and worth reading — "Invalid
|
|
70
|
+
* playlist items. Length mismatch" is the whole diagnosis. But the thing that
|
|
71
|
+
* answers is not always Audiobookshelf: a reverse proxy, an SSO portal or an
|
|
72
|
+
* outbound filter writes its own body, and under `AUDIOBOOKSHELF_INSECURE_TLS`
|
|
73
|
+
* so can anything that can reach the address. So the text is stripped, cut and
|
|
74
|
+
* labelled as what it is.
|
|
75
|
+
*/
|
|
76
|
+
export declare function upstreamText(text: string, max?: number): string;
|
|
77
|
+
/**
|
|
78
|
+
* A value shortened for a sentence.
|
|
79
|
+
*
|
|
80
|
+
* For the messages that have to name what was rejected — a media type that is
|
|
81
|
+
* not "book", a content type that is not JSON — without letting a hundred
|
|
82
|
+
* kilobytes of the instance's choosing into the model's context.
|
|
83
|
+
*/
|
|
84
|
+
export declare function quoted(value: string, max?: number): string;
|
|
85
|
+
/**
|
|
86
|
+
* Refuses a header value the HTTP layer would refuse, without quoting it.
|
|
87
|
+
*
|
|
88
|
+
* undici's own refusal is `Headers.append: "<value>" is an invalid header
|
|
89
|
+
* value.` — the whole value, in a `TypeError` that this server turns into a
|
|
90
|
+
* tool result. The one header this server builds from a secret is
|
|
91
|
+
* `Authorization`, so an API key with a line break in the middle of it — a
|
|
92
|
+
* wrapped paste — would arrive in the model's context by way of an error
|
|
93
|
+
* message. Refusing first means the runtime never gets to quote one.
|
|
94
|
+
*
|
|
95
|
+
* The message names the header and where the offending character sits, which
|
|
96
|
+
* is what someone fixing a pasted credential needs, and nothing else.
|
|
97
|
+
*/
|
|
98
|
+
export declare function assertHeaderValue(name: string, value: string): void;
|
|
99
|
+
/** Whether a field of this name holds a credential. */
|
|
100
|
+
export declare function isCredentialKey(key: string): boolean;
|
|
101
|
+
/** What {@link redactCredentials} put in place of a value, and where. */
|
|
102
|
+
export interface RedactionReport {
|
|
103
|
+
/** Dotted paths of the fields that were replaced, in encounter order. */
|
|
104
|
+
readonly removed: string[];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Replaces credential-shaped fields anywhere in a record the API returned.
|
|
108
|
+
*
|
|
109
|
+
* The reason this exists at all is `GET /api/me`. It answers with
|
|
110
|
+
* `User.toOldJSONForBrowser()`, and `MeController.getCurrentUser` calls it
|
|
111
|
+
* without `hideRootToken` — so the document carries `token`, the account's old
|
|
112
|
+
* non-expiring access token, for the root user included. `detail: "full"`
|
|
113
|
+
* hands the raw record on, which put a credential that outlives this process
|
|
114
|
+
* into a model's context and into whatever that model's operator logs.
|
|
115
|
+
*
|
|
116
|
+
* Written as a walk over every pass-through record rather than as a `delete`
|
|
117
|
+
* on that one field: the projection is not the API, and the next release of
|
|
118
|
+
* either is free to add a second one.
|
|
119
|
+
*/
|
|
120
|
+
export declare function redactCredentials(value: unknown, report?: RedactionReport, path?: string): {
|
|
121
|
+
value: unknown;
|
|
122
|
+
report: RedactionReport;
|
|
123
|
+
};
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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}
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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. */
|