calibreweb-mcp 0.1.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/LICENSE +21 -0
- package/README.md +167 -0
- package/dist/api.d.ts +33 -0
- package/dist/api.js +231 -0
- package/dist/api.js.map +1 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.js +98 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/redact.d.ts +9 -0
- package/dist/redact.js +21 -0
- package/dist/redact.js.map +1 -0
- package/dist/result.d.ts +21 -0
- package/dist/result.js +105 -0
- package/dist/result.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +32 -0
- package/dist/server.js.map +1 -0
- package/dist/shape.d.ts +142 -0
- package/dist/shape.js +369 -0
- package/dist/shape.js.map +1 -0
- package/dist/tools/books.d.ts +3 -0
- package/dist/tools/books.js +114 -0
- package/dist/tools/books.js.map +1 -0
- package/dist/tools/covers.d.ts +3 -0
- package/dist/tools/covers.js +53 -0
- package/dist/tools/covers.js.map +1 -0
- package/dist/tools/shelves.d.ts +3 -0
- package/dist/tools/shelves.js +65 -0
- package/dist/tools/shelves.js.map +1 -0
- package/dist/tools/stats.d.ts +3 -0
- package/dist/tools/stats.js +25 -0
- package/dist/tools/stats.js.map +1 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Willi Thiel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# calibreweb-mcp
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ni-c/calibreweb-mcp/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/calibreweb-mcp)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/ni-c/calibreweb-mcp/pkgs/container/calibreweb-mcp)
|
|
8
|
+
|
|
9
|
+
A read-only [Model Context Protocol](https://modelcontextprotocol.io) server for
|
|
10
|
+
[Calibre-Web](https://github.com/janeczku/calibre-web) (and
|
|
11
|
+
[Calibre-Web Automated](https://github.com/crocodilestick/Calibre-Web-Automated)),
|
|
12
|
+
the self-hosted ebook library web UI.
|
|
13
|
+
|
|
14
|
+
Calibre-Web has no REST API — its only stable machine-readable interface is the
|
|
15
|
+
OPDS catalog feed it serves for e-reader apps. This server speaks that feed:
|
|
16
|
+
Atom XML with HTTP Basic auth in, structured book data out. Search the library,
|
|
17
|
+
browse the curated views and shelves, follow per-format download links, and pull
|
|
18
|
+
cover images straight into the conversation. It never writes anything: every
|
|
19
|
+
tool is a GET against the OPDS routes.
|
|
20
|
+
|
|
21
|
+
<!-- <picture> is resolved against the colour scheme of the page showing it, so GitHub
|
|
22
|
+
picks the variant that matches its own theme toggle. npm strips <picture> and
|
|
23
|
+
<source> when it sanitises the README and keeps the <img>, which is why that
|
|
24
|
+
fallback brings its own dark card instead of relying on a media query. -->
|
|
25
|
+
<picture>
|
|
26
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://calibreweb-mcp.ni-c.de/architecture-dark.svg">
|
|
27
|
+
<source media="(prefers-color-scheme: light)" srcset="https://calibreweb-mcp.ni-c.de/architecture-light.svg">
|
|
28
|
+
<img src="https://calibreweb-mcp.ni-c.de/architecture.svg" alt="An MCP client speaks stdio to calibreweb-mcp, which reads the OPDS feed of Calibre-Web over HTTPS with Basic auth" width="800">
|
|
29
|
+
</picture>
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
- Node.js 22 or newer
|
|
34
|
+
- A Calibre-Web instance (developed against the current
|
|
35
|
+
`linuxserver/calibre-web` image; Calibre-Web Automated works the same way)
|
|
36
|
+
- A Calibre-Web user for the server. The OPDS feed authenticates with the
|
|
37
|
+
normal web login — use a **dedicated account with only the View and Download
|
|
38
|
+
roles**, not your admin account. If the instance allows anonymous browsing,
|
|
39
|
+
the server can also run without credentials.
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
| Variable | Required | Description |
|
|
44
|
+
| -------------------------- | -------- | ------------------------------------------------------------------------------------------------------- |
|
|
45
|
+
| `CALIBRE_WEB_URL` | yes | Root URL of the instance, e.g. `https://books.example.com`. The `/opds` path is appended automatically. |
|
|
46
|
+
| `CALIBRE_WEB_USERNAME` | yes¹ | Username of the Calibre-Web account. |
|
|
47
|
+
| `CALIBRE_WEB_PASSWORD` | yes¹ | Password of that account (the web login password). |
|
|
48
|
+
| `CALIBRE_WEB_INSECURE_TLS` | no | `true` to accept self-signed certificates — scoped to the configured host only. |
|
|
49
|
+
|
|
50
|
+
¹ Leave **both** unset for an instance that allows anonymous browsing; setting
|
|
51
|
+
only one of them is a configuration error.
|
|
52
|
+
|
|
53
|
+
### Claude Code
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
claude mcp add calibreweb \
|
|
57
|
+
-e CALIBRE_WEB_URL=https://books.example.com \
|
|
58
|
+
-e CALIBRE_WEB_USERNAME=reader \
|
|
59
|
+
-e CALIBRE_WEB_PASSWORD=... \
|
|
60
|
+
-- npx calibreweb-mcp
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Claude Desktop
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"mcpServers": {
|
|
68
|
+
"calibreweb": {
|
|
69
|
+
"command": "npx",
|
|
70
|
+
"args": ["calibreweb-mcp"],
|
|
71
|
+
"env": {
|
|
72
|
+
"CALIBRE_WEB_URL": "https://books.example.com",
|
|
73
|
+
"CALIBRE_WEB_USERNAME": "reader",
|
|
74
|
+
"CALIBRE_WEB_PASSWORD": "..."
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Codex
|
|
82
|
+
|
|
83
|
+
```toml
|
|
84
|
+
[mcp_servers.calibreweb]
|
|
85
|
+
command = "npx"
|
|
86
|
+
args = ["calibreweb-mcp"]
|
|
87
|
+
env = { CALIBRE_WEB_URL = "https://books.example.com", CALIBRE_WEB_USERNAME = "reader", CALIBRE_WEB_PASSWORD = "..." }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Tools
|
|
91
|
+
|
|
92
|
+
All tools are read-only (`readOnlyHint: true`).
|
|
93
|
+
|
|
94
|
+
| Tool | Description |
|
|
95
|
+
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
96
|
+
| `search_books` | Search by title, author, series, publisher and tags. Calibre-Web returns every match at once; the result is capped client-side (`limit`, default 50) and reports the real match count. |
|
|
97
|
+
| `list_books` | Book listings by view: `new` (default), `hot`, `rated`, `discover` (random), `read`, `unread`, or `all` (optionally narrowed to an initial letter). |
|
|
98
|
+
| `list_shelves` | Public shelves plus the configured user's own shelves. |
|
|
99
|
+
| `get_shelf_books` | The books on a shelf, in shelf order. |
|
|
100
|
+
| `get_cover` | A book's cover, returned as an image the client can display. |
|
|
101
|
+
| `get_stats` | Total books, authors, categories and series. |
|
|
102
|
+
|
|
103
|
+
Book entries include authors, tags, series (with index), rating, a bounded
|
|
104
|
+
summary, a cover URL and per-format download URLs — ready-made links a human can
|
|
105
|
+
open, since the model itself has no reason to download an EPUB.
|
|
106
|
+
|
|
107
|
+
### Pagination
|
|
108
|
+
|
|
109
|
+
Feeds are paginated by the instance's _books per page_ setting (default 60); the
|
|
110
|
+
page size is not client-controllable. Every listing returns
|
|
111
|
+
`pagination.nextOffset` when more pages exist — pass it as `offset` in the next
|
|
112
|
+
call. The `discover` view is a random selection and not paginated.
|
|
113
|
+
|
|
114
|
+
### Deliberately out of scope
|
|
115
|
+
|
|
116
|
+
- **No writes.** The OPDS feed has none, and this server would not add any.
|
|
117
|
+
- **No file downloads.** Tools return download URLs, not ebook payloads.
|
|
118
|
+
- **No facet browsing** (authors/series/tags/publishers/languages/formats as
|
|
119
|
+
their own index feeds). `search_books` covers those lookups; the routes exist
|
|
120
|
+
and tools for them can be added if there is a real use case.
|
|
121
|
+
|
|
122
|
+
## Safety
|
|
123
|
+
|
|
124
|
+
- The server is read-only by construction — GET requests only, no state anywhere.
|
|
125
|
+
- Book metadata is untrusted third-party data; every result says so, control
|
|
126
|
+
characters are stripped, and XML entity processing is disabled (documents
|
|
127
|
+
declaring a DOCTYPE or entities are refused outright).
|
|
128
|
+
- Responses are bounded before parsing (8 MB feeds, 1 MB covers) and again
|
|
129
|
+
before they reach the model (per-book and per-response budgets).
|
|
130
|
+
- Feed hrefs are only passed through when they resolve to the configured
|
|
131
|
+
origin over http(s) — a hostile feed cannot plant `javascript:`, `file:` or
|
|
132
|
+
cross-origin URLs into the results.
|
|
133
|
+
- Redirects are refused so the Basic credentials can never be replayed to
|
|
134
|
+
another host; covers are only passed through for real image content types.
|
|
135
|
+
- The password is scrubbed from the process environment at startup, and URLs
|
|
136
|
+
are credential-redacted before they appear in any log or result.
|
|
137
|
+
|
|
138
|
+
## Container
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
docker run -i --rm \
|
|
142
|
+
-e CALIBRE_WEB_URL=https://books.example.com \
|
|
143
|
+
-e CALIBRE_WEB_USERNAME=reader \
|
|
144
|
+
-e CALIBRE_WEB_PASSWORD=... \
|
|
145
|
+
ghcr.io/ni-c/calibreweb-mcp
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
```sh
|
|
151
|
+
npm install
|
|
152
|
+
npm test # unit tests against a stubbed OPDS feed, no instance needed
|
|
153
|
+
npm run lint
|
|
154
|
+
npm run build
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
158
|
+
|
|
159
|
+
## Releasing
|
|
160
|
+
|
|
161
|
+
1. Update `CHANGELOG.md` and bump the version in `package.json` (+ lockfile).
|
|
162
|
+
2. `npm run lint && npm run test:coverage && npm run build`
|
|
163
|
+
3. Tag the release: `git tag -s vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z`
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
[MIT](LICENSE)
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Config } from './config.js';
|
|
2
|
+
export declare class CalibreWebApiError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly body: string;
|
|
5
|
+
constructor(status: number, body: string, method: string, path: string);
|
|
6
|
+
}
|
|
7
|
+
/** Minimal client for the Calibre-Web OPDS endpoints, using HTTP Basic auth. */
|
|
8
|
+
export declare class CalibreWebApi {
|
|
9
|
+
private readonly config;
|
|
10
|
+
private readonly baseUrl;
|
|
11
|
+
/** Unset when both credentials are absent — anonymous-browsing instances. */
|
|
12
|
+
private readonly authHeader?;
|
|
13
|
+
/**
|
|
14
|
+
* Only set when `CALIBRE_WEB_INSECURE_TLS` is enabled. Scopes the relaxed
|
|
15
|
+
* certificate validation to requests against the configured host instead of
|
|
16
|
+
* disabling it process-wide via NODE_TLS_REJECT_UNAUTHORIZED.
|
|
17
|
+
*/
|
|
18
|
+
private readonly insecureDispatcher?;
|
|
19
|
+
constructor(config: Config);
|
|
20
|
+
/** Base URL for absolutizing feed hrefs; empty string when unconfigured. */
|
|
21
|
+
get url(): string;
|
|
22
|
+
private send;
|
|
23
|
+
/** Fetches an OPDS feed and returns the parsed XML document. */
|
|
24
|
+
getFeed(path: string, params?: Record<string, string | number | undefined>): Promise<unknown>;
|
|
25
|
+
/** Fetches a JSON endpoint (`/opds/stats`). */
|
|
26
|
+
getJson(path: string): Promise<unknown>;
|
|
27
|
+
/** Fetches a binary body (cover images), bounded by {@link MAX_COVER_BYTES}. */
|
|
28
|
+
getBinary(path: string): Promise<{
|
|
29
|
+
data: Buffer;
|
|
30
|
+
contentType: string;
|
|
31
|
+
}>;
|
|
32
|
+
private isConfiguredOrigin;
|
|
33
|
+
}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
2
|
+
import { Agent, fetch as undiciFetch, } from 'undici';
|
|
3
|
+
import { missingConfigKeys, missingConfigMessage, } from './config.js';
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
5
|
+
/**
|
|
6
|
+
* Ceiling on a single feed response. The OPDS search endpoint is not paginated
|
|
7
|
+
* server-side — a broad query on a huge library returns every match in one
|
|
8
|
+
* document — and the per-tool budgets in `shape.ts` and `result.ts` only trim
|
|
9
|
+
* data that is already resident as a string. 8 MB is far above any legitimate
|
|
10
|
+
* feed page and far below trouble.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_FEED_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
/**
|
|
14
|
+
* Ceiling on a cover image. Calibre-Web serves the full-size cover on the OPDS
|
|
15
|
+
* cover route (the thumbnail aliases are client compat only). The result goes
|
|
16
|
+
* into the client's context as base64 (+33%), so this is deliberately tight —
|
|
17
|
+
* an oversized scan is better retrieved out-of-band via the book's coverUrl.
|
|
18
|
+
*/
|
|
19
|
+
const MAX_COVER_BYTES = 1 * 1024 * 1024;
|
|
20
|
+
export class CalibreWebApiError extends Error {
|
|
21
|
+
status;
|
|
22
|
+
body;
|
|
23
|
+
constructor(status, body, method, path) {
|
|
24
|
+
super(`Calibre-Web ${method} ${path} failed with HTTP ${status}`);
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.body = body;
|
|
27
|
+
this.name = 'CalibreWebApiError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The parser is deliberately dumb: no entity processing (the five XML built-ins
|
|
32
|
+
* and numeric references are decoded — with a control-character guard — in
|
|
33
|
+
* `shape.ts`), no value coercion (a book titled "1984" must stay a string), and
|
|
34
|
+
* the xhtml `<content>` blob is kept as a raw string for `htmlToText` instead
|
|
35
|
+
* of being exploded into objects.
|
|
36
|
+
*/
|
|
37
|
+
const feedParser = new XMLParser({
|
|
38
|
+
ignoreAttributes: false,
|
|
39
|
+
attributeNamePrefix: '@_',
|
|
40
|
+
processEntities: false,
|
|
41
|
+
parseTagValue: false,
|
|
42
|
+
parseAttributeValue: false,
|
|
43
|
+
isArray: (name) => ['entry', 'link', 'author', 'category', 'dcterms:language'].includes(name),
|
|
44
|
+
stopNodes: ['feed.entry.content'],
|
|
45
|
+
});
|
|
46
|
+
/**
|
|
47
|
+
* Refuses any document that declares a DTD or entities. The parser above does
|
|
48
|
+
* not process entities, so there is no local expansion exposure — this guard
|
|
49
|
+
* exists so that can never silently change with a parser update, and because a
|
|
50
|
+
* legitimate Calibre-Web feed simply never contains a DOCTYPE.
|
|
51
|
+
*/
|
|
52
|
+
function assertNoDoctype(xml, path) {
|
|
53
|
+
if (/<!(doctype|entity)\b/i.test(xml)) {
|
|
54
|
+
throw new Error(`Calibre-Web GET ${path} returned XML containing a DOCTYPE or ENTITY ` +
|
|
55
|
+
'declaration, which this server refuses to parse.');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Minimal client for the Calibre-Web OPDS endpoints, using HTTP Basic auth. */
|
|
59
|
+
export class CalibreWebApi {
|
|
60
|
+
config;
|
|
61
|
+
baseUrl;
|
|
62
|
+
/** Unset when both credentials are absent — anonymous-browsing instances. */
|
|
63
|
+
authHeader;
|
|
64
|
+
/**
|
|
65
|
+
* Only set when `CALIBRE_WEB_INSECURE_TLS` is enabled. Scopes the relaxed
|
|
66
|
+
* certificate validation to requests against the configured host instead of
|
|
67
|
+
* disabling it process-wide via NODE_TLS_REJECT_UNAUTHORIZED.
|
|
68
|
+
*/
|
|
69
|
+
insecureDispatcher;
|
|
70
|
+
constructor(config) {
|
|
71
|
+
this.config = config;
|
|
72
|
+
this.baseUrl = config.url ?? '';
|
|
73
|
+
if (config.username && config.password) {
|
|
74
|
+
this.authHeader = `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`;
|
|
75
|
+
}
|
|
76
|
+
if (config.insecureTls) {
|
|
77
|
+
this.insecureDispatcher = new Agent({
|
|
78
|
+
connect: { rejectUnauthorized: false },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Base URL for absolutizing feed hrefs; empty string when unconfigured. */
|
|
83
|
+
get url() {
|
|
84
|
+
return this.baseUrl;
|
|
85
|
+
}
|
|
86
|
+
async send(path, accept, params) {
|
|
87
|
+
// The credentials are only required here, not at startup, so the server can
|
|
88
|
+
// still be started and introspected without them.
|
|
89
|
+
const missing = missingConfigKeys(this.config);
|
|
90
|
+
if (missing.length > 0) {
|
|
91
|
+
throw new Error(missingConfigMessage(missing));
|
|
92
|
+
}
|
|
93
|
+
const headers = { Accept: accept };
|
|
94
|
+
if (this.authHeader !== undefined) {
|
|
95
|
+
headers.Authorization = this.authHeader;
|
|
96
|
+
}
|
|
97
|
+
const init = {
|
|
98
|
+
method: 'GET',
|
|
99
|
+
headers,
|
|
100
|
+
// Never follow a redirect: it would resend the Basic credentials to
|
|
101
|
+
// whatever host the upstream points at. (Calibre-Web redirects to its
|
|
102
|
+
// HTML login page when Basic auth is refused behind some proxies — that
|
|
103
|
+
// case surfaces as an explicit error here instead.)
|
|
104
|
+
redirect: 'error',
|
|
105
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
106
|
+
};
|
|
107
|
+
const search = new URLSearchParams();
|
|
108
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
109
|
+
if (value !== undefined)
|
|
110
|
+
search.set(key, String(value));
|
|
111
|
+
}
|
|
112
|
+
const query = search.size > 0 ? `?${search.toString()}` : '';
|
|
113
|
+
const url = `${this.baseUrl}${path}${query}`;
|
|
114
|
+
// The insecure dispatcher requires undici's own fetch; the default path
|
|
115
|
+
// uses the (stubbable) global fetch. Only requests that actually go to the
|
|
116
|
+
// configured instance may use the relaxed dispatcher.
|
|
117
|
+
const useInsecure = this.insecureDispatcher !== undefined && this.isConfiguredOrigin(url);
|
|
118
|
+
const response = useInsecure
|
|
119
|
+
? (await undiciFetch(url, {
|
|
120
|
+
...init,
|
|
121
|
+
dispatcher: this.insecureDispatcher,
|
|
122
|
+
}))
|
|
123
|
+
: await fetch(url, init);
|
|
124
|
+
return {
|
|
125
|
+
status: response.status,
|
|
126
|
+
ok: response.ok,
|
|
127
|
+
headers: response.headers,
|
|
128
|
+
response,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Fetches an OPDS feed and returns the parsed XML document. */
|
|
132
|
+
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);
|
|
135
|
+
const text = bytes.toString('utf8');
|
|
136
|
+
if (!ok) {
|
|
137
|
+
throw new CalibreWebApiError(status, text, 'GET', path);
|
|
138
|
+
}
|
|
139
|
+
const trimmed = text.trimStart();
|
|
140
|
+
if (/^(<!doctype\s+html|<html[\s>])/i.test(trimmed)) {
|
|
141
|
+
throw new Error(`Calibre-Web GET ${path} returned an HTML page instead of an Atom feed — ` +
|
|
142
|
+
'CALIBRE_WEB_URL is probably not the root of the Calibre-Web instance, ' +
|
|
143
|
+
'or a proxy in front of it answered with a login page.');
|
|
144
|
+
}
|
|
145
|
+
assertNoDoctype(text, path);
|
|
146
|
+
try {
|
|
147
|
+
return feedParser.parse(text);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new Error(`Calibre-Web GET ${path} did not return parseable Atom XML — check CALIBRE_WEB_URL.`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Fetches a JSON endpoint (`/opds/stats`). */
|
|
154
|
+
async getJson(path) {
|
|
155
|
+
const { ok, status, response } = await this.send(path, 'application/json');
|
|
156
|
+
const bytes = await readBoundedBody(response, path, MAX_FEED_BYTES);
|
|
157
|
+
const text = bytes.toString('utf8');
|
|
158
|
+
if (!ok) {
|
|
159
|
+
throw new CalibreWebApiError(status, text, 'GET', path);
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(text);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw new Error(`Calibre-Web GET ${path} did not return valid JSON — check CALIBRE_WEB_URL.`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Fetches a binary body (cover images), bounded by {@link MAX_COVER_BYTES}. */
|
|
169
|
+
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') ?? '' };
|
|
176
|
+
}
|
|
177
|
+
isConfiguredOrigin(url) {
|
|
178
|
+
try {
|
|
179
|
+
return new URL(url).origin === new URL(this.baseUrl).origin;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function hasStreamingBody(body) {
|
|
187
|
+
return (typeof body === 'object' &&
|
|
188
|
+
body !== null &&
|
|
189
|
+
typeof body.getReader === 'function');
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Reads a response body, refusing anything past `maxBytes`.
|
|
193
|
+
*
|
|
194
|
+
* A declared `content-length` is rejected before a single byte is read; a
|
|
195
|
+
* chunked response is aborted as soon as the accumulated size crosses the
|
|
196
|
+
* ceiling. Responses without a streamable body — which is what the test stubs
|
|
197
|
+
* of global `fetch` return — fall back to `arrayBuffer()` and are checked
|
|
198
|
+
* afterwards.
|
|
199
|
+
*/
|
|
200
|
+
async function readBoundedBody(response, path, maxBytes) {
|
|
201
|
+
const tooLarge = () => new Error(`Calibre-Web GET ${path} returned a response larger than ` +
|
|
202
|
+
`${maxBytes} bytes and was refused.`);
|
|
203
|
+
const declared = Number(response.headers.get('content-length'));
|
|
204
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
205
|
+
throw tooLarge();
|
|
206
|
+
const body = response.body;
|
|
207
|
+
if (!hasStreamingBody(body)) {
|
|
208
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
209
|
+
if (buffer.byteLength > maxBytes)
|
|
210
|
+
throw tooLarge();
|
|
211
|
+
return buffer;
|
|
212
|
+
}
|
|
213
|
+
const reader = body.getReader();
|
|
214
|
+
const chunks = [];
|
|
215
|
+
let total = 0;
|
|
216
|
+
for (;;) {
|
|
217
|
+
const { done, value } = await reader.read();
|
|
218
|
+
if (done)
|
|
219
|
+
break;
|
|
220
|
+
if (value === undefined)
|
|
221
|
+
continue;
|
|
222
|
+
total += value.byteLength;
|
|
223
|
+
if (total > maxBytes) {
|
|
224
|
+
await reader.cancel();
|
|
225
|
+
throw tooLarge();
|
|
226
|
+
}
|
|
227
|
+
chunks.push(value);
|
|
228
|
+
}
|
|
229
|
+
return Buffer.concat(chunks);
|
|
230
|
+
}
|
|
231
|
+
//# sourceMappingURL=api.js.map
|
package/dist/api.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface Config {
|
|
2
|
+
/**
|
|
3
|
+
* Base URL of the Calibre-Web instance, e.g. `https://books.example.com` —
|
|
4
|
+
* the root of the web UI; the `/opds` path is appended automatically. May be
|
|
5
|
+
* undefined together with the credentials: the server still starts and lists
|
|
6
|
+
* its tools, every API call then fails with {@link missingConfigMessage}.
|
|
7
|
+
*/
|
|
8
|
+
url: string | undefined;
|
|
9
|
+
username: string | undefined;
|
|
10
|
+
/** The normal web-login password of the Calibre-Web user (OPDS uses HTTP Basic auth). */
|
|
11
|
+
password: string | undefined;
|
|
12
|
+
insecureTls: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** Shown when the configuration is incomplete — at startup and on every API call. */
|
|
15
|
+
export declare function missingConfigMessage(missing: string[]): string;
|
|
16
|
+
/**
|
|
17
|
+
* Names of the required environment variables that are unset in `config`.
|
|
18
|
+
*
|
|
19
|
+
* Username and password count as a pair: both unset is a supported mode
|
|
20
|
+
* (instances with anonymous browsing serve the OPDS feed without auth), only
|
|
21
|
+
* one of them set is a configuration error.
|
|
22
|
+
*/
|
|
23
|
+
export declare function missingConfigKeys(config: Config): string[];
|
|
24
|
+
/**
|
|
25
|
+
* Reads the configuration from environment variables.
|
|
26
|
+
*
|
|
27
|
+
* Missing credentials are only a warning, not a fatal error: the server must be
|
|
28
|
+
* able to complete the MCP handshake and answer `tools/list` without them, so
|
|
29
|
+
* registries and sandbox inspectors can introspect it. A malformed URL still
|
|
30
|
+
* exits — that one could send the credentials to the wrong host.
|
|
31
|
+
*/
|
|
32
|
+
export declare function loadConfig(env?: NodeJS.ProcessEnv): Config;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { redactUrlCredentials } from './redact.js';
|
|
2
|
+
/** Shown when the configuration is incomplete — at startup and on every API call. */
|
|
3
|
+
export function missingConfigMessage(missing) {
|
|
4
|
+
return (`missing required environment variable(s): ${missing.join(', ')}\n` +
|
|
5
|
+
'Required: CALIBRE_WEB_URL (e.g. https://books.example.com), ' +
|
|
6
|
+
'CALIBRE_WEB_USERNAME, CALIBRE_WEB_PASSWORD\n' +
|
|
7
|
+
'The credentials are the normal web login of a Calibre-Web user; the OPDS ' +
|
|
8
|
+
'feed authenticates with HTTP Basic auth. If the instance allows anonymous ' +
|
|
9
|
+
'browsing, leave BOTH username and password unset.\n' +
|
|
10
|
+
'Optional: CALIBRE_WEB_INSECURE_TLS=true to accept self-signed certificates');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Names of the required environment variables that are unset in `config`.
|
|
14
|
+
*
|
|
15
|
+
* Username and password count as a pair: both unset is a supported mode
|
|
16
|
+
* (instances with anonymous browsing serve the OPDS feed without auth), only
|
|
17
|
+
* one of them set is a configuration error.
|
|
18
|
+
*/
|
|
19
|
+
export function missingConfigKeys(config) {
|
|
20
|
+
const missing = [];
|
|
21
|
+
if (!config.url)
|
|
22
|
+
missing.push('CALIBRE_WEB_URL');
|
|
23
|
+
if (!config.username !== !config.password) {
|
|
24
|
+
missing.push(!config.username ? 'CALIBRE_WEB_USERNAME' : 'CALIBRE_WEB_PASSWORD');
|
|
25
|
+
}
|
|
26
|
+
return missing;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Reads the configuration from environment variables.
|
|
30
|
+
*
|
|
31
|
+
* Missing credentials are only a warning, not a fatal error: the server must be
|
|
32
|
+
* able to complete the MCP handshake and answer `tools/list` without them, so
|
|
33
|
+
* registries and sandbox inspectors can introspect it. A malformed URL still
|
|
34
|
+
* exits — that one could send the credentials to the wrong host.
|
|
35
|
+
*/
|
|
36
|
+
export function loadConfig(env = process.env) {
|
|
37
|
+
const url = env.CALIBRE_WEB_URL;
|
|
38
|
+
const username = env.CALIBRE_WEB_USERNAME;
|
|
39
|
+
const password = env.CALIBRE_WEB_PASSWORD;
|
|
40
|
+
const insecureTls = env.CALIBRE_WEB_INSECURE_TLS === 'true';
|
|
41
|
+
// Don't keep the password in process.env for the process lifetime: it would be
|
|
42
|
+
// inherited by child processes and show up in env dumps. (The kernel's
|
|
43
|
+
// /proc/<pid>/environ snapshot is NOT rewritten by this — it always keeps the
|
|
44
|
+
// startup environment.) Deleted before any early return below so no code path
|
|
45
|
+
// leaves it behind.
|
|
46
|
+
delete env.CALIBRE_WEB_PASSWORD;
|
|
47
|
+
if (!url) {
|
|
48
|
+
console.error(`calibreweb-mcp: ${missingConfigMessage(['CALIBRE_WEB_URL'])}`);
|
|
49
|
+
return { url: undefined, username, password, insecureTls };
|
|
50
|
+
}
|
|
51
|
+
if (!username !== !password) {
|
|
52
|
+
console.error('calibreweb-mcp: CALIBRE_WEB_USERNAME and CALIBRE_WEB_PASSWORD must be ' +
|
|
53
|
+
'set together (or both left unset for an instance with anonymous browsing)');
|
|
54
|
+
}
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = new URL(url);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Redacted, and deliberately so: the userinfo check below only runs once the
|
|
61
|
+
// URL parses, so a value that does not parse at all but still carries
|
|
62
|
+
// credentials — "https://admin:s3cret@host:99999", an out-of-range port —
|
|
63
|
+
// would otherwise print the password into the MCP client's log file.
|
|
64
|
+
console.error(`calibreweb-mcp: CALIBRE_WEB_URL is not a valid URL: ${redactUrlCredentials(url)}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
68
|
+
console.error(`calibreweb-mcp: CALIBRE_WEB_URL must use http:// or https:// (got ${parsed.protocol})`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
// Credentials embedded in the URL would end up in logs and error messages.
|
|
72
|
+
if (parsed.username || parsed.password) {
|
|
73
|
+
console.error('calibreweb-mcp: CALIBRE_WEB_URL must not contain credentials — use ' +
|
|
74
|
+
'CALIBRE_WEB_USERNAME and CALIBRE_WEB_PASSWORD');
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) {
|
|
78
|
+
console.error('calibreweb-mcp: WARNING: CALIBRE_WEB_URL uses plain http to a non-local ' +
|
|
79
|
+
'host — the password will be sent unencrypted. Use https:// instead.');
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
url: url.replace(/\/+$/, ''),
|
|
83
|
+
username,
|
|
84
|
+
password,
|
|
85
|
+
insecureTls,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function isLoopbackHost(hostname) {
|
|
89
|
+
// URL.hostname keeps the brackets around an IPv6 literal, so comparing against
|
|
90
|
+
// a bare '::1' never matches and the plain-http warning fires on a loopback
|
|
91
|
+
// URL written as http://[::1]:8083.
|
|
92
|
+
const host = hostname.replace(/^\[|\]$/g, '');
|
|
93
|
+
return (host === 'localhost' ||
|
|
94
|
+
host.endsWith('.localhost') ||
|
|
95
|
+
host.startsWith('127.') ||
|
|
96
|
+
host === '::1');
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
import { createServer } from './server.js';
|
|
5
|
+
async function main() {
|
|
6
|
+
const config = loadConfig();
|
|
7
|
+
if (config.insecureTls) {
|
|
8
|
+
console.error('calibreweb-mcp: CALIBRE_WEB_INSECURE_TLS=true — TLS certificate validation is disabled for the Calibre-Web connection');
|
|
9
|
+
}
|
|
10
|
+
const server = createServer(config);
|
|
11
|
+
// stdout belongs to the protocol; everything human-readable goes to stderr.
|
|
12
|
+
await server.connect(new StdioServerTransport());
|
|
13
|
+
console.error(config.url
|
|
14
|
+
? `calibreweb-mcp: connected, targeting ${config.url}`
|
|
15
|
+
: 'calibreweb-mcp: connected without configuration — tools are listed but every call will fail');
|
|
16
|
+
}
|
|
17
|
+
main().catch((error) => {
|
|
18
|
+
console.error('calibreweb-mcp: fatal error:', error);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
});
|
|
21
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AAEjF,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,KAAK,UAAU,IAAI;IACjB,MAAM,MAAM,GAAG,UAAU,EAAE,CAAC;IAE5B,IAAI,MAAM,CAAC,WAAW,EAAE,CAAC;QACvB,OAAO,CAAC,KAAK,CACX,uHAAuH,CACxH,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;IACpC,4EAA4E;IAC5E,MAAM,MAAM,CAAC,OAAO,CAAC,IAAI,oBAAoB,EAAE,CAAC,CAAC;IACjD,OAAO,CAAC,KAAK,CACX,MAAM,CAAC,GAAG;QACR,CAAC,CAAC,wCAAwC,MAAM,CAAC,GAAG,EAAE;QACtD,CAAC,CAAC,6FAA6F,CAClG,CAAC;AACJ,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;IAC9B,OAAO,CAAC,KAAK,CAAC,8BAA8B,EAAE,KAAK,CAAC,CAAC;IACrD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes credentials from a URL before it reaches the model or a log.
|
|
3
|
+
*
|
|
4
|
+
* The URLs this server emits are built from `CALIBRE_WEB_URL` plus feed hrefs, so
|
|
5
|
+
* they should never carry userinfo — but a misconfigured value or a reverse proxy
|
|
6
|
+
* rewriting `Location`-style hrefs could smuggle one in, and Basic-auth users are
|
|
7
|
+
* exactly the audience that pastes `https://user:pass@host` into config files.
|
|
8
|
+
*/
|
|
9
|
+
export declare function redactUrlCredentials(url: string): string;
|