calibreweb-mcp 0.2.0 → 0.3.1

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 (43) hide show
  1. package/README.md +125 -35
  2. package/dist/api.d.ts +22 -1
  3. package/dist/api.js +119 -17
  4. package/dist/config.js +42 -8
  5. package/dist/index.js +25 -5
  6. package/dist/redact.js +10 -1
  7. package/dist/result.d.ts +26 -4
  8. package/dist/result.js +97 -25
  9. package/dist/server.d.ts +1 -1
  10. package/dist/server.js +51 -5
  11. package/dist/shape.d.ts +101 -38
  12. package/dist/shape.js +448 -70
  13. package/dist/tools/annotations.d.ts +19 -0
  14. package/dist/tools/annotations.js +20 -0
  15. package/dist/tools/books.d.ts +1 -1
  16. package/dist/tools/books.js +38 -10
  17. package/dist/tools/catalogue.d.ts +7 -6
  18. package/dist/tools/catalogue.js +6 -5
  19. package/dist/tools/covers.d.ts +1 -1
  20. package/dist/tools/covers.js +91 -22
  21. package/dist/tools/shelves.d.ts +1 -1
  22. package/dist/tools/shelves.js +26 -10
  23. package/dist/tools/stats.d.ts +1 -1
  24. package/dist/tools/stats.js +24 -3
  25. package/package.json +22 -16
  26. package/dist/api.js.map +0 -1
  27. package/dist/config.js.map +0 -1
  28. package/dist/hosts.d.ts +0 -22
  29. package/dist/hosts.js +0 -144
  30. package/dist/hosts.js.map +0 -1
  31. package/dist/index.js.map +0 -1
  32. package/dist/redact.js.map +0 -1
  33. package/dist/result.js.map +0 -1
  34. package/dist/server.js.map +0 -1
  35. package/dist/shape.js.map +0 -1
  36. package/dist/tool-filter.d.ts +0 -43
  37. package/dist/tool-filter.js +0 -138
  38. package/dist/tool-filter.js.map +0 -1
  39. package/dist/tools/books.js.map +0 -1
  40. package/dist/tools/catalogue.js.map +0 -1
  41. package/dist/tools/covers.js.map +0 -1
  42. package/dist/tools/shelves.js.map +0 -1
  43. package/dist/tools/stats.js.map +0 -1
package/README.md CHANGED
@@ -1,13 +1,19 @@
1
1
  # calibreweb-mcp
2
2
 
3
+ <!-- badges: start -->
4
+
3
5
  [![CI](https://img.shields.io/github/actions/workflow/status/ni-c/calibreweb-mcp/ci.yml?branch=main&label=CI)](https://github.com/ni-c/calibreweb-mcp/actions/workflows/ci.yml)
6
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/ni-c/calibreweb-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/ni-c/calibreweb-mcp)
7
+ <a href="https://socket.dev/npm/package/calibreweb-mcp"><img src="https://socket.dev/api/badge/npm/package/calibreweb-mcp" alt="Socket supply-chain report" height="20"></a>
8
+ [![Glama score](https://glama.ai/mcp/servers/ni-c/calibreweb-mcp/badges/score.svg)](https://glama.ai/mcp/servers/ni-c/calibreweb-mcp)
9
+ <br>
4
10
  [![npm version](https://img.shields.io/npm/v/calibreweb-mcp)](https://www.npmjs.com/package/calibreweb-mcp)
5
- [![npm downloads](https://img.shields.io/npm/dm/calibreweb-mcp)](https://www.npmjs.com/package/calibreweb-mcp)
6
- [![node](https://img.shields.io/node/v/calibreweb-mcp)](https://nodejs.org)
7
- [![license](https://img.shields.io/npm/l/calibreweb-mcp)](LICENSE)
8
- [![container](https://img.shields.io/badge/ghcr.io-ni--c%2Fcalibreweb--mcp-blue)](https://github.com/ni-c/calibreweb-mcp/pkgs/container/calibreweb-mcp)
9
- [![docs](https://img.shields.io/badge/docs-calibreweb--mcp.ni--c.de-informational)](https://calibreweb-mcp.ni-c.de)
11
+ [![container image](https://img.shields.io/badge/ghcr.io-ni--c%2Fcalibreweb--mcp-4f46e5?logo=docker&logoColor=white)](https://github.com/ni-c/calibreweb-mcp/pkgs/container/calibreweb-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-calibreweb--mcp.ni--c.de-4f46e5?logo=readthedocs&logoColor=white)](https://calibreweb-mcp.ni-c.de)
10
15
  [![sponsor](https://img.shields.io/badge/sponsor-ni--c-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/ni-c)
16
+ <!-- badges: end -->
11
17
 
12
18
  A read-only [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for
13
19
  [Calibre-Web](https://github.com/janeczku/calibre-web) (and
@@ -42,6 +48,18 @@ HTTP Basic auth in, structured book data out.
42
48
 
43
49
  ![Demo: listing the tools, searching the library and reading the stats through the MCP Inspector CLI](https://calibreweb-mcp.ni-c.de/demo.gif)
44
50
 
51
+ ## What makes it different
52
+
53
+ **The API Calibre-Web never had.** Calibre-Web exposes no REST API — its only
54
+ stable machine interface is the OPDS Atom feed built for e-reader apps. These
55
+ tools parse that feed into structured book data with numeric ids, per-format
56
+ download URLs and bounded summaries.
57
+
58
+ **Read-only by construction.** All six tools are GETs. Redirects are refused so
59
+ Basic credentials never travel, XML carrying a DOCTYPE is rejected outright,
60
+ hrefs are locked to the configured origin, and metadata is marked as the
61
+ untrusted data it is.
62
+
45
63
  ## Requirements
46
64
 
47
65
  - Node.js 22 or newer
@@ -66,6 +84,29 @@ HTTP Basic auth in, structured book data out.
66
84
  ¹ Leave **both** unset for an instance that allows anonymous browsing; setting
67
85
  only one of them is a configuration error.
68
86
 
87
+ ### Choosing which tools load
88
+
89
+ `CALIBRE_WEB_ALLOW_TOOLS` and `CALIBRE_WEB_DENY_TOOLS` take comma-separated tool names;
90
+ a trailing `*` matches a whole family. `essential` is a curated preset of
91
+ five: `search_books`, `list_books`, `list_shelves`, `get_shelf_books`, `get_stats`.
92
+
93
+ ```sh
94
+ CALIBRE_WEB_ALLOW_TOOLS=essential
95
+ CALIBRE_WEB_ALLOW_TOOLS=search_books,list_shelves
96
+ CALIBRE_WEB_DENY_TOOLS=get_cover
97
+ ```
98
+
99
+ An entry that matches no tool aborts startup and names it, so a typo cannot
100
+ silently hide a tool — an absent tool is not something anyone traces back to an
101
+ environment variable. A filtered tool is never registered, so it is absent from
102
+ `tools/list` and unknown to `tools/call` alike.
103
+
104
+ If you run several of these servers at once, [mcp-hub](https://mcp-hub.ni-c.de)
105
+ is the other answer — its `/hub` endpoint replaces every server's tools with six
106
+ meta-tools.
107
+
108
+ ## Installation
109
+
69
110
  ### Claude Code
70
111
 
71
112
  ```sh
@@ -103,6 +144,46 @@ args = ["calibreweb-mcp"]
103
144
  env = { CALIBRE_WEB_URL = "https://books.example.com", CALIBRE_WEB_USERNAME = "reader", CALIBRE_WEB_PASSWORD = "..." }
104
145
  ```
105
146
 
147
+ ### Docker
148
+
149
+ ```sh
150
+ docker run -i --rm \
151
+ -e CALIBRE_WEB_URL=https://books.example.com \
152
+ -e CALIBRE_WEB_USERNAME=reader \
153
+ -e CALIBRE_WEB_PASSWORD=... \
154
+ ghcr.io/ni-c/calibreweb-mcp
155
+ ```
156
+
157
+ ### Through mcp-hub
158
+
159
+ A client that cannot spawn a local process — ChatGPT connectors, Claude on the web,
160
+ Cursor, LibreChat — reaches calibreweb-mcp through [mcp-hub](https://mcp-hub.ni-c.de): one
161
+ container serves many stdio MCP servers over Streamable HTTP, with an OAuth 2.1 login
162
+ behind a single password and long-lived tokens for the clients that cannot do OAuth. Its
163
+ `/hub` endpoint puts every server behind six meta-tools, so one connector reaches all of
164
+ them without N×tool schemas in the model's context, and it speaks both protocol revisions
165
+ — a question this server asks travels through it to the person at the far end.
166
+
167
+ Its `/config/mcp.json` uses Claude Code's format, so the entry is the one you already
168
+ have:
169
+
170
+ ```json
171
+ {
172
+ "mcpServers": {
173
+ "calibreweb": {
174
+ "command": "npx",
175
+ "args": ["-y", "calibreweb-mcp"],
176
+ "env": { "CALIBRE_WEB_ALLOW_TOOLS": "essential" },
177
+ "denyTools": ["get_cover"]
178
+ }
179
+ }
180
+ }
181
+ ```
182
+
183
+ `allowTools` and `denyTools` there are the hub's **own** per-server filter, which is not
184
+ the same thing as `*_ALLOW_TOOLS` in `env` — the difference, and the mistake it invites,
185
+ are in the [client guide](https://calibreweb-mcp.ni-c.de/guide/clients#through-mcp-hub).
186
+
106
187
  ## Tools
107
188
 
108
189
  All tools are read-only (`readOnlyHint: true`).
@@ -120,6 +201,34 @@ Book entries include authors, tags, series (with index), rating, a bounded
120
201
  summary, a cover URL and per-format download URLs — ready-made links a human can
121
202
  open, since the model itself has no reason to download an EPUB.
122
203
 
204
+ ### Structured output
205
+
206
+ Every tool declares an `outputSchema` and answers with `structuredContent`
207
+ alongside the text block, so a client can use the result without parsing prose:
208
+
209
+ ```jsonc
210
+ {
211
+ "untrusted": true,
212
+ "source": "calibre-web",
213
+ "totalFound": 2,
214
+ "truncated": false,
215
+ "books": [{ "id": 7, "title": "Dune", "authors": ["Frank Herbert"] }],
216
+ "notes": ["Book titles, authors, tags, series and summaries come from …"],
217
+ }
218
+ ```
219
+
220
+ The `untrusted` marker is a field and not only a line in `notes`, because a
221
+ client that reads the structured half would otherwise have to find the warning
222
+ in a list of sentences. The two tools without it are `get_stats`, which is four
223
+ counters this server has checked are numbers, and `get_cover`, which reports an
224
+ id, a media type from a four-entry allowlist and a byte count — the image itself
225
+ stays in the content block where a client renders it.
226
+
227
+ An over-budget result drops book summaries as before. Where that is still not
228
+ enough it is now an **error** rather than JSON cut at the ceiling: unparseable
229
+ text was tolerable in a text block and is not something `structuredContent` can
230
+ carry, and the two channels have to hold the same value.
231
+
123
232
  ### Pagination
124
233
 
125
234
  Feeds are paginated by the instance's _books per page_ setting (default 60); the
@@ -127,7 +236,7 @@ page size is not client-controllable. Every listing returns
127
236
  `pagination.nextOffset` when more pages exist — pass it as `offset` in the next
128
237
  call. The `discover` view is a random selection and not paginated.
129
238
 
130
- ### Deliberately out of scope
239
+ ## Not exposed, on purpose
131
240
 
132
241
  - **No writes.** The OPDS feed has none, and this server would not add any.
133
242
  - **No file downloads.** Tools return download URLs, not ebook payloads.
@@ -151,15 +260,10 @@ call. The `discover` view is a random selection and not paginated.
151
260
  - The password is scrubbed from the process environment at startup, and URLs
152
261
  are credential-redacted before they appear in any log or result.
153
262
 
154
- ## Container
263
+ ## Documentation
155
264
 
156
- ```sh
157
- docker run -i --rm \
158
- -e CALIBRE_WEB_URL=https://books.example.com \
159
- -e CALIBRE_WEB_USERNAME=reader \
160
- -e CALIBRE_WEB_PASSWORD=... \
161
- ghcr.io/ni-c/calibreweb-mcp
162
- ```
265
+ The full guide, tool reference and security notes live at
266
+ **[calibreweb-mcp.ni-c.de](https://calibreweb-mcp.ni-c.de)** (source in [`docs/`](docs/)).
163
267
 
164
268
  ## Development
165
269
 
@@ -178,27 +282,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
178
282
  2. `npm run lint && npm run test:coverage && npm run build`
179
283
  3. Tag the release: `git tag -s vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z`
180
284
 
181
- ## License
182
-
183
- [MIT](LICENSE)
285
+ ## Contributing
184
286
 
185
- ### Choosing which tools load
287
+ Issues, discussions and pull requests are welcome — see
288
+ [CONTRIBUTING.md](CONTRIBUTING.md). For vulnerabilities please use
289
+ [private reporting](https://github.com/ni-c/calibreweb-mcp/security/advisories/new)
290
+ rather than a public issue; the policy is in [SECURITY.md](SECURITY.md).
186
291
 
187
- `CALIBRE_WEB_ALLOW_TOOLS` and `CALIBRE_WEB_DENY_TOOLS` take comma-separated tool names;
188
- a trailing `*` matches a whole family. `essential` is a curated preset of
189
- five: `search_books`, `list_books`, `list_shelves`, `get_shelf_books`, `get_stats`.
190
-
191
- ```sh
192
- CALIBRE_WEB_ALLOW_TOOLS=essential
193
- CALIBRE_WEB_ALLOW_TOOLS=search_books,list_shelves
194
- CALIBRE_WEB_DENY_TOOLS=get_cover
195
- ```
196
-
197
- An entry that matches no tool aborts startup and names it, so a typo cannot
198
- silently hide a tool — an absent tool is not something anyone traces back to an
199
- environment variable. A filtered tool is never registered, so it is absent from
200
- `tools/list` and unknown to `tools/call` alike.
292
+ ## License
201
293
 
202
- If you run several of these servers at once, [mcp-hub](https://mcp-hub.ni-c.de)
203
- is the other answer — its `/hub` endpoint replaces every server's tools with six
204
- meta-tools.
294
+ [MIT](LICENSE) © Willi Thiel
package/dist/api.d.ts CHANGED
@@ -2,7 +2,11 @@ import { type Config } from './config.js';
2
2
  export declare class CalibreWebApiError extends Error {
3
3
  readonly status: number;
4
4
  readonly body: string;
5
- constructor(status: number, body: string, method: string, path: string);
5
+ /** Set when this answer was repeated from memory rather than requested. */
6
+ readonly note?: string | undefined;
7
+ constructor(status: number, body: string, method: string, path: string,
8
+ /** Set when this answer was repeated from memory rather than requested. */
9
+ note?: string | undefined);
6
10
  }
7
11
  /** Minimal client for the Calibre-Web OPDS endpoints, using HTTP Basic auth. */
8
12
  export declare class CalibreWebApi {
@@ -16,10 +20,27 @@ export declare class CalibreWebApi {
16
20
  * disabling it process-wide via NODE_TLS_REJECT_UNAUTHORIZED.
17
21
  */
18
22
  private readonly insecureDispatcher?;
23
+ /**
24
+ * The last refused login, until {@link AUTH_REFUSAL_MEMORY_MS} has passed.
25
+ *
26
+ * Per process, which is the honest scope: a restart forgets it, and so does
27
+ * a second server instance.
28
+ */
29
+ private authRefusal;
19
30
  constructor(config: Config);
20
31
  /** Base URL for absolutizing feed hrefs; empty string when unconfigured. */
21
32
  get url(): string;
22
33
  private send;
34
+ /**
35
+ * Decides on the status before a byte of the body is read.
36
+ *
37
+ * The other order — read under the success ceiling, then look at `ok` — made
38
+ * a 401 behind a reverse proxy that answers with a login page report itself
39
+ * as "returned a response larger than 1048576 bytes and was refused": the
40
+ * size instead of the status, no hint about the credentials, and none of the
41
+ * handling that keys on 401 ever running.
42
+ */
43
+ private expectOk;
23
44
  /** Fetches an OPDS feed and returns the parsed XML document. */
24
45
  getFeed(path: string, params?: Record<string, string | number | undefined>): Promise<unknown>;
25
46
  /** Fetches a JSON endpoint (`/opds/stats`). */
package/dist/api.js CHANGED
@@ -17,13 +17,44 @@ const MAX_FEED_BYTES = 8 * 1024 * 1024;
17
17
  * an oversized scan is better retrieved out-of-band via the book's coverUrl.
18
18
  */
19
19
  const MAX_COVER_BYTES = 1 * 1024 * 1024;
20
+ /**
21
+ * Ceiling on the JSON of `/opds/stats`, which is four counters.
22
+ *
23
+ * The feed ceiling was doing this job, and eight megabytes for four numbers is
24
+ * not a ceiling — it is the absence of one at the scale that matters.
25
+ */
26
+ const MAX_STATS_BYTES = 64 * 1024;
27
+ /**
28
+ * Ceiling on an error body, which is read to be quoted and nothing else.
29
+ *
30
+ * Separate from the success ceilings on purpose: this reader cuts instead of
31
+ * refusing, so a reverse proxy answering a 401 with a two-megabyte login page
32
+ * still surfaces as a 401 with a hint about the credentials.
33
+ */
34
+ const MAX_ERROR_BODY_BYTES = 64 * 1024;
35
+ /**
36
+ * How long a refused login is remembered, in milliseconds.
37
+ *
38
+ * Calibre-Web's `verify_password` writes `OPDS Login failed for user "%s"
39
+ * IP-address: %s` at warning level for every refusal — the line fail2ban
40
+ * filters on — and the rate limiter that would otherwise cap the attempts is
41
+ * commented out in that function, with no limiter on the OPDS routes at all.
42
+ * Every tool here is annotated read-only, idempotent and cheap, which is
43
+ * exactly what a model retries after "check your credentials". One wrong
44
+ * password should not become a banned address.
45
+ */
46
+ const AUTH_REFUSAL_MEMORY_MS = 10_000;
20
47
  export class CalibreWebApiError extends Error {
21
48
  status;
22
49
  body;
23
- constructor(status, body, method, path) {
50
+ note;
51
+ constructor(status, body, method, path,
52
+ /** Set when this answer was repeated from memory rather than requested. */
53
+ note) {
24
54
  super(`Calibre-Web ${method} ${path} failed with HTTP ${status}`);
25
55
  this.status = status;
26
56
  this.body = body;
57
+ this.note = note;
27
58
  this.name = 'CalibreWebApiError';
28
59
  }
29
60
  }
@@ -67,6 +98,13 @@ export class CalibreWebApi {
67
98
  * disabling it process-wide via NODE_TLS_REJECT_UNAUTHORIZED.
68
99
  */
69
100
  insecureDispatcher;
101
+ /**
102
+ * The last refused login, until {@link AUTH_REFUSAL_MEMORY_MS} has passed.
103
+ *
104
+ * Per process, which is the honest scope: a restart forgets it, and so does
105
+ * a second server instance.
106
+ */
107
+ authRefusal;
70
108
  constructor(config) {
71
109
  this.config = config;
72
110
  this.baseUrl = config.url ?? '';
@@ -90,6 +128,17 @@ export class CalibreWebApi {
90
128
  if (missing.length > 0) {
91
129
  throw new Error(missingConfigMessage(missing));
92
130
  }
131
+ const refusal = this.authRefusal;
132
+ if (refusal !== undefined) {
133
+ if (Date.now() - refusal.at < AUTH_REFUSAL_MEMORY_MS) {
134
+ throw new CalibreWebApiError(refusal.status, refusal.body, 'GET', path, 'Repeated from memory: this login was refused less than ' +
135
+ `${AUTH_REFUSAL_MEMORY_MS / 1000} seconds ago and was not tried again — ` +
136
+ 'Calibre-Web logs every refused OPDS login and does not rate-limit ' +
137
+ 'them, so a retry loop is what gets an address banned. Next attempt ' +
138
+ `possible at ${new Date(refusal.at + AUTH_REFUSAL_MEMORY_MS).toISOString()}.`);
139
+ }
140
+ this.authRefusal = undefined;
141
+ }
93
142
  const headers = { Accept: accept };
94
143
  if (this.authHeader !== undefined) {
95
144
  headers.Authorization = this.authHeader;
@@ -128,14 +177,30 @@ export class CalibreWebApi {
128
177
  response,
129
178
  };
130
179
  }
180
+ /**
181
+ * Decides on the status before a byte of the body is read.
182
+ *
183
+ * The other order — read under the success ceiling, then look at `ok` — made
184
+ * a 401 behind a reverse proxy that answers with a login page report itself
185
+ * as "returned a response larger than 1048576 bytes and was refused": the
186
+ * size instead of the status, no hint about the credentials, and none of the
187
+ * handling that keys on 401 ever running.
188
+ */
189
+ async expectOk(path, result) {
190
+ if (result.ok)
191
+ return;
192
+ const body = await readErrorBody(result.response);
193
+ if (result.status === 401) {
194
+ this.authRefusal = { status: 401, body, at: Date.now() };
195
+ }
196
+ throw new CalibreWebApiError(result.status, body, 'GET', path);
197
+ }
131
198
  /** Fetches an OPDS feed and returns the parsed XML document. */
132
199
  async getFeed(path, params) {
133
- const { ok, status, response } = await this.send(path, 'application/atom+xml', params);
134
- const bytes = await readBoundedBody(response, path, MAX_FEED_BYTES);
200
+ const result = await this.send(path, 'application/atom+xml', params);
201
+ await this.expectOk(path, result);
202
+ const bytes = await readBoundedBody(result.response, path, MAX_FEED_BYTES);
135
203
  const text = bytes.toString('utf8');
136
- if (!ok) {
137
- throw new CalibreWebApiError(status, text, 'GET', path);
138
- }
139
204
  const trimmed = text.trimStart();
140
205
  if (/^(<!doctype\s+html|<html[\s>])/i.test(trimmed)) {
141
206
  throw new Error(`Calibre-Web GET ${path} returned an HTML page instead of an Atom feed — ` +
@@ -152,12 +217,10 @@ export class CalibreWebApi {
152
217
  }
153
218
  /** Fetches a JSON endpoint (`/opds/stats`). */
154
219
  async getJson(path) {
155
- const { ok, status, response } = await this.send(path, 'application/json');
156
- const bytes = await readBoundedBody(response, path, MAX_FEED_BYTES);
220
+ const result = await this.send(path, 'application/json');
221
+ await this.expectOk(path, result);
222
+ const bytes = await readBoundedBody(result.response, path, MAX_STATS_BYTES);
157
223
  const text = bytes.toString('utf8');
158
- if (!ok) {
159
- throw new CalibreWebApiError(status, text, 'GET', path);
160
- }
161
224
  try {
162
225
  return JSON.parse(text);
163
226
  }
@@ -167,12 +230,10 @@ export class CalibreWebApi {
167
230
  }
168
231
  /** Fetches a binary body (cover images), bounded by {@link MAX_COVER_BYTES}. */
169
232
  async getBinary(path) {
170
- const { ok, status, headers, response } = await this.send(path, 'image/*');
171
- const data = await readBoundedBody(response, path, MAX_COVER_BYTES);
172
- if (!ok) {
173
- throw new CalibreWebApiError(status, data.toString('utf8'), 'GET', path);
174
- }
175
- return { data, contentType: headers.get('content-type') ?? '' };
233
+ const result = await this.send(path, 'image/*');
234
+ await this.expectOk(path, result);
235
+ const data = await readBoundedBody(result.response, path, MAX_COVER_BYTES);
236
+ return { data, contentType: result.headers.get('content-type') ?? '' };
176
237
  }
177
238
  isConfiguredOrigin(url) {
178
239
  try {
@@ -183,6 +244,47 @@ export class CalibreWebApi {
183
244
  }
184
245
  }
185
246
  }
247
+ /**
248
+ * Reads at most {@link MAX_ERROR_BODY_BYTES} of an error body, and never
249
+ * throws.
250
+ *
251
+ * An error body exists to be quoted in the error message. Refusing to read it
252
+ * because it is large would replace a status the caller can act on with a size
253
+ * nobody can, which is the failure this function was written to end.
254
+ */
255
+ async function readErrorBody(response) {
256
+ try {
257
+ const body = response.body;
258
+ if (!hasStreamingBody(body)) {
259
+ const buffer = Buffer.from(await response.arrayBuffer());
260
+ return buffer.subarray(0, MAX_ERROR_BODY_BYTES).toString('utf8');
261
+ }
262
+ const reader = body.getReader();
263
+ const chunks = [];
264
+ let total = 0;
265
+ for (;;) {
266
+ const { done, value } = await reader.read();
267
+ if (done)
268
+ break;
269
+ if (value === undefined)
270
+ continue;
271
+ chunks.push(value);
272
+ total += value.byteLength;
273
+ if (total >= MAX_ERROR_BODY_BYTES) {
274
+ await reader.cancel();
275
+ break;
276
+ }
277
+ }
278
+ return Buffer.concat(chunks)
279
+ .subarray(0, MAX_ERROR_BODY_BYTES)
280
+ .toString('utf8');
281
+ }
282
+ catch {
283
+ // A body that cannot be read is not an error worth replacing the status
284
+ // with; the status is the answer.
285
+ return '';
286
+ }
287
+ }
186
288
  function hasStreamingBody(body) {
187
289
  return (typeof body === 'object' &&
188
290
  body !== null &&
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { internalHostKind } from './hosts.js';
1
+ import { internalHostKind } from 'mcp-internal-hosts';
2
2
  import { redactUrlCredentials } from './redact.js';
3
3
  /** Shown when the configuration is incomplete — at startup and on every API call. */
4
4
  export function missingConfigMessage(missing) {
@@ -67,15 +67,18 @@ export function loadConfig(env = process.env) {
67
67
  parsed = new URL(url);
68
68
  }
69
69
  catch {
70
- // Redacted, and deliberately so: the userinfo check below only runs once the
71
- // URL parses, so a value that does not parse at all but still carries
72
- // credentials "https://admin:s3cret@host:99999", an out-of-range port
73
- // would otherwise print the password into the MCP client's log file.
74
- console.error(`calibreweb-mcp: CALIBRE_WEB_URL is not a valid URL: ${redactUrlCredentials(url)}`);
70
+ // The value that does not parse at all is the one most likely to be the
71
+ // secret: a password pasted one line too high fails `new URL()`, and
72
+ // redacting userinfo does nothing for a bare one. Quote it only when it
73
+ // looks like a URL at all, and describe the rest by length.
74
+ console.error(`calibreweb-mcp: CALIBRE_WEB_URL is not a valid URL: ${describeValue(url)}`);
75
75
  process.exit(1);
76
76
  }
77
77
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
78
- console.error(`calibreweb-mcp: CALIBRE_WEB_URL must use http:// or https:// (got ${parsed.protocol})`);
78
+ // Not `(got ${parsed.protocol})`: a 56-character hexadecimal key with a
79
+ // colon after it is a valid URL whose scheme is the key, and that branch
80
+ // would print it in full.
81
+ console.error('calibreweb-mcp: CALIBRE_WEB_URL must use http:// or https://');
79
82
  process.exit(1);
80
83
  }
81
84
  // Credentials embedded in the URL would end up in logs and error messages.
@@ -88,8 +91,17 @@ export function loadConfig(env = process.env) {
88
91
  console.error('calibreweb-mcp: WARNING: CALIBRE_WEB_URL uses plain http to a non-local ' +
89
92
  'host — the password will be sent unencrypted. Use https:// instead.');
90
93
  }
94
+ if (parsed.search !== '' || parsed.hash !== '') {
95
+ console.error('calibreweb-mcp: CALIBRE_WEB_URL carried a query string or fragment; ' +
96
+ 'both were dropped — only the origin and path are used.');
97
+ }
91
98
  return {
92
- url: url.replace(/\/+$/, ''),
99
+ // The parsed origin and path, not the environment string: a stray space,
100
+ // query or fragment in that string was glued in front of every request
101
+ // path. And the trailing slashes come off with an index walk rather than
102
+ // `replace(/\/+$/, '')`, which is tried from every position of the run and
103
+ // took 1.6 seconds on 80 000 of them.
104
+ url: parsed.origin + trimTrailingSlashes(parsed.pathname),
93
105
  username,
94
106
  password,
95
107
  insecureTls,
@@ -97,6 +109,28 @@ export function loadConfig(env = process.env) {
97
109
  denyTools,
98
110
  };
99
111
  }
112
+ /**
113
+ * Quotes a configuration value only when it has the shape of a URL.
114
+ *
115
+ * `redactUrlCredentials` takes the userinfo out of something that *is* a URL;
116
+ * it cannot help with a value that is a password, an API token or a path. Those
117
+ * are described by length instead, which is all a person debugging their
118
+ * configuration needs.
119
+ */
120
+ function describeValue(value) {
121
+ if (!value.includes('://')) {
122
+ return `a ${value.length}-character value that does not look like a URL`;
123
+ }
124
+ const redacted = redactUrlCredentials(value);
125
+ return redacted.length > 120 ? `${redacted.slice(0, 120)}...` : redacted;
126
+ }
127
+ /** Trailing `/` removed with one walk and one slice. */
128
+ function trimTrailingSlashes(path) {
129
+ let end = path.length;
130
+ while (end > 0 && path.charCodeAt(end - 1) === 47)
131
+ end -= 1;
132
+ return path.slice(0, end);
133
+ }
100
134
  function isLoopbackHost(hostname) {
101
135
  // The shared classifier, so every spelling of a loopback address is
102
136
  // recognised — including http://[::ffff:127.0.0.1] and 'localhost.' with its
package/dist/index.js CHANGED
@@ -1,16 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
3
3
  import { loadConfig } from './config.js';
4
4
  import { createServer } from './server.js';
5
- import { ToolFilterError } from './tool-filter.js';
5
+ import { ToolFilterError } from 'mcp-tool-allowlist';
6
6
  async function main() {
7
7
  const config = loadConfig();
8
8
  if (config.insecureTls) {
9
9
  console.error('calibreweb-mcp: CALIBRE_WEB_INSECURE_TLS=true — TLS certificate validation is disabled for the Calibre-Web connection');
10
10
  }
11
- let server;
11
+ // Built before anything is served, so a rejected tool filter still ends
12
+ // the process rather than surfacing as a failed handshake once a client
13
+ // has already connected.
14
+ let pending;
12
15
  try {
13
- server = createServer(config);
16
+ pending = createServer(config);
14
17
  }
15
18
  catch (error) {
16
19
  // A bad tool list is operator feedback, not a crash: print the
@@ -22,11 +25,28 @@ async function main() {
22
25
  throw error;
23
26
  }
24
27
  // stdout belongs to the protocol; everything human-readable goes to stderr.
25
- await server.connect(new StdioServerTransport());
28
+ // `serveStdio` owns the era decision for the connection: the opening
29
+ // exchange selects 2025-11-25 or 2026-07-28 and pins one instance from
30
+ // this factory for its lifetime. A hand-wired `StdioServerTransport`
31
+ // serves only the 2025 era, which is why a negotiating client’s
32
+ // `server/discover` probe was answered with "Method not found".
33
+ //
34
+ // The instance built above serves the first connection; a second call — a
35
+ // modern probe followed by the real connection — builds a fresh one, which
36
+ // is safe because `createServer` only registers tools.
37
+ serveStdio(() => {
38
+ const server = pending ?? createServer(config);
39
+ pending = undefined;
40
+ return server;
41
+ });
26
42
  console.error(config.url
27
43
  ? `calibreweb-mcp: connected, targeting ${config.url}`
28
44
  : 'calibreweb-mcp: connected without configuration — tools are listed but every call will fail');
29
45
  }
46
+ // In a container node runs as PID 1 with no default signal disposition, so
47
+ // without this handler `docker stop` waits out the grace period and SIGKILLs.
48
+ process.on('SIGTERM', () => process.exit(0));
49
+ process.on('SIGINT', () => process.exit(0));
30
50
  main().catch((error) => {
31
51
  console.error('calibreweb-mcp: fatal error:', error);
32
52
  process.exit(1);
package/dist/redact.js CHANGED
@@ -5,8 +5,17 @@
5
5
  * that is already percent- or XML-encoded is handed back byte-identical when it
6
6
  * holds no credentials, and a value that is *not* a valid URL — the case
7
7
  * `loadConfig` reports on — still gets redacted.
8
+ *
9
+ * The class excludes `/?#` but deliberately not `@`, because userinfo ends at
10
+ * the *last* `@` before the path, not the first. A password may legitimately
11
+ * contain one and nothing percent-encodes it on the way in — the audience for
12
+ * this function are the people who paste `https://user:pass@host` into a config
13
+ * file. Stopping at the first `@` published the tail of such a password:
14
+ * `https://alice:p@ssw0rd@host` came back as `https://***@ssw0rd@host`. Not
15
+ * crossing `/` is what keeps `https://host/users/@alice` untouched, since no
16
+ * `@` is reachable from the scheme without passing the path.
8
17
  */
9
- const URL_USERINFO = /^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i;
18
+ const URL_USERINFO = /^([a-z][a-z0-9+.-]*:\/\/)[^/?#]*@/i;
10
19
  /**
11
20
  * Removes credentials from a URL before it reaches the model or a log.
12
21
  *
package/dist/result.d.ts CHANGED
@@ -1,14 +1,36 @@
1
- import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
1
+ import type { CallToolResult } from '@modelcontextprotocol/server';
2
2
  export declare function textResult(text: string): CallToolResult;
3
3
  /**
4
- * Serializes a result, stripping book summaries if the payload is still
5
- * pathologically large after the per-tool truncation.
4
+ * An answer in both channels at once, with book summaries stripped if the
5
+ * payload is still pathologically large after the per-tool truncation.
6
6
  *
7
7
  * A Calibre library can hold book descriptions of arbitrary length, and the
8
8
  * OPDS search endpoint returns every match in one feed. Everything downstream
9
9
  * of this function assumes the budget held; this is what guarantees it.
10
+ *
11
+ * `structuredContent` is the machine-readable half and the reason every tool
12
+ * here declares an `outputSchema`; the text block stays because the SDK does
13
+ * NOT synthesize one for an object-shaped value, and a client that reads only
14
+ * `content` would otherwise get an empty answer. Both carry the same object —
15
+ * which is why the over-budget path rebuilds the *value* rather than editing
16
+ * its serialization.
10
17
  */
11
- export declare function jsonResult(data: unknown): CallToolResult;
18
+ export declare function jsonResult(data: Record<string, unknown>): CallToolResult;
19
+ /**
20
+ * {@link jsonResult}, with the untrusted-content marker on the object.
21
+ *
22
+ * The note has always gone out in `notes`, which is in the text block and in
23
+ * the structured half alike. The two fields are what a client reading only
24
+ * `structuredContent` can *check* rather than have to find in a list of
25
+ * sentences — and they are stripped from the payload before they are set, so
26
+ * the guard cannot be switched off by the content it guards against.
27
+ */
28
+ export declare function untrustedResult(data: Record<string, unknown>): CallToolResult;
29
+ /** Raised by {@link jsonResult}; `run` turns it into an error result. */
30
+ export declare class ResultTooLargeError extends Error {
31
+ }
32
+ /** A value in both channels, with no budget applied. */
33
+ export declare function structuredResult(data: Record<string, unknown>, rendered?: string): CallToolResult;
12
34
  export declare function errorResult(text: string): CallToolResult;
13
35
  /** Thrown by tools for problems detected before any request goes out. */
14
36
  export declare class ToolInputError extends Error {