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.
- package/README.md +125 -35
- package/dist/api.d.ts +22 -1
- package/dist/api.js +119 -17
- package/dist/config.js +42 -8
- package/dist/index.js +25 -5
- package/dist/redact.js +10 -1
- package/dist/result.d.ts +26 -4
- package/dist/result.js +97 -25
- package/dist/server.d.ts +1 -1
- package/dist/server.js +51 -5
- package/dist/shape.d.ts +101 -38
- package/dist/shape.js +448 -70
- package/dist/tools/annotations.d.ts +19 -0
- package/dist/tools/annotations.js +20 -0
- package/dist/tools/books.d.ts +1 -1
- package/dist/tools/books.js +38 -10
- package/dist/tools/catalogue.d.ts +7 -6
- package/dist/tools/catalogue.js +6 -5
- package/dist/tools/covers.d.ts +1 -1
- package/dist/tools/covers.js +91 -22
- package/dist/tools/shelves.d.ts +1 -1
- package/dist/tools/shelves.js +26 -10
- package/dist/tools/stats.d.ts +1 -1
- package/dist/tools/stats.js +24 -3
- package/package.json +22 -16
- package/dist/api.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/hosts.d.ts +0 -22
- package/dist/hosts.js +0 -144
- package/dist/hosts.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/redact.js.map +0 -1
- package/dist/result.js.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/shape.js.map +0 -1
- package/dist/tool-filter.d.ts +0 -43
- package/dist/tool-filter.js +0 -138
- package/dist/tool-filter.js.map +0 -1
- package/dist/tools/books.js.map +0 -1
- package/dist/tools/catalogue.js.map +0 -1
- package/dist/tools/covers.js.map +0 -1
- package/dist/tools/shelves.js.map +0 -1
- 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
|
[](https://github.com/ni-c/calibreweb-mcp/actions/workflows/ci.yml)
|
|
6
|
+
[](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
|
+
[](https://glama.ai/mcp/servers/ni-c/calibreweb-mcp)
|
|
9
|
+
<br>
|
|
4
10
|
[](https://www.npmjs.com/package/calibreweb-mcp)
|
|
5
|
-
[](https://calibreweb-mcp.ni-c.de)
|
|
11
|
+
[](https://github.com/ni-c/calibreweb-mcp/pkgs/container/calibreweb-mcp)
|
|
12
|
+
[](https://mcp-hub.ni-c.de)
|
|
13
|
+
<br>
|
|
14
|
+
[](https://calibreweb-mcp.ni-c.de)
|
|
10
15
|
[](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
|

|
|
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
|
-
|
|
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
|
-
##
|
|
263
|
+
## Documentation
|
|
155
264
|
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
##
|
|
182
|
-
|
|
183
|
-
[MIT](LICENSE)
|
|
285
|
+
## Contributing
|
|
184
286
|
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
134
|
-
|
|
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
|
|
156
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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 '
|
|
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
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
console.error(`calibreweb-mcp: CALIBRE_WEB_URL is not a valid 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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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+.-]*:\/\/)[
|
|
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/
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/server';
|
|
2
2
|
export declare function textResult(text: string): CallToolResult;
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
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 {
|