annas-mcp 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nikita Sokolsky
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,179 @@
1
+ # annas-mcp
2
+
3
+ [![Tests](https://github.com/SokolskyNikita/annas-mcp/actions/workflows/test.yml/badge.svg)](https://github.com/SokolskyNikita/annas-mcp/actions/workflows/test.yml)
4
+ [![Release](https://img.shields.io/github/v/release/SokolskyNikita/annas-mcp)](https://github.com/SokolskyNikita/annas-mcp/releases)
5
+
6
+ An MCP server that lets Claude, Cursor, Codex and other AI clients search [Anna's Archive](https://annas-archive.gl) for books and papers and download the file you pick. It also works as a command-line tool.
7
+
8
+ | Tool | What it does |
9
+ | --- | --- |
10
+ | `book_search` | Finds books, textbooks, manuals and standards by title, author or topic |
11
+ | `article_search` | Finds journal articles by keyword, or looks one up by DOI |
12
+ | `book_download` | Downloads a book using the hash from a search result |
13
+ | `article_download` | Downloads an article by DOI or hash |
14
+
15
+ Use it only for material you are entitled to obtain, under the laws and terms that apply to you.
16
+
17
+ ## Setup
18
+
19
+ You need Node.js 18 or newer and an Anna's Archive [membership](https://annas-archive.gl/donate):
20
+
21
+ | To… | Membership |
22
+ | --- | --- |
23
+ | Search | **Lucky Librarian** or higher |
24
+ | Download | Any tier |
25
+
26
+ ### 1. Get your credentials
27
+
28
+ - **Account cookie.** Sign in to Anna's Archive, open your browser's developer tools and go to Application (Chrome, Edge) or Storage (Firefox, Safari) → Cookies. Copy the value of `aa_account_id2`. It expires weekly; see [Renewing the cookie](#renewing-the-cookie).
29
+ - **API key.** Copy it from your Anna's Archive account page. See the [API FAQ](https://annas-archive.gl/faq#api). Only needed for downloads.
30
+
31
+ ### 2. Add the server to your client
32
+
33
+ **Claude Code**
34
+
35
+ ```bash
36
+ claude mcp add annas-mcp \
37
+ --env ANNAS_ACCOUNT_COOKIE=your-cookie \
38
+ --env ANNAS_SECRET_KEY=your-api-key \
39
+ --env ANNAS_DOWNLOAD_PATH=/absolute/path/to/downloads \
40
+ -- npx -y github:SokolskyNikita/annas-mcp
41
+ ```
42
+
43
+ On native Windows (not WSL), end the command with `-- cmd /c npx -y github:SokolskyNikita/annas-mcp`.
44
+
45
+ **Claude Desktop and Cursor.** Add this to Claude Desktop's config (Settings → Developer → Edit Config) or to Cursor's `~/.cursor/mcp.json`:
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "annas-mcp": {
51
+ "command": "npx",
52
+ "args": ["-y", "github:SokolskyNikita/annas-mcp"],
53
+ "env": {
54
+ "ANNAS_ACCOUNT_COOKIE": "your-cookie",
55
+ "ANNAS_SECRET_KEY": "your-api-key",
56
+ "ANNAS_DOWNLOAD_PATH": "/absolute/path/to/downloads"
57
+ }
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ **Codex.** Add this to `~/.codex/config.toml`:
64
+
65
+ ```toml
66
+ [mcp_servers.annas-mcp]
67
+ command = "npx"
68
+ args = ["-y", "github:SokolskyNikita/annas-mcp"]
69
+
70
+ [mcp_servers.annas-mcp.env]
71
+ ANNAS_ACCOUNT_COOKIE = "your-cookie"
72
+ ANNAS_SECRET_KEY = "your-api-key"
73
+ ANNAS_DOWNLOAD_PATH = "/absolute/path/to/downloads"
74
+ ```
75
+
76
+ Pick a permanent `ANNAS_DOWNLOAD_PATH`. Folders under `/tmp` are cleared by the operating system.
77
+
78
+ ### 3. Try it
79
+
80
+ Restart your client and ask something like *"Find the EPUB of Pride and Prejudice and download it."* The AI searches, picks a copy and returns the path of the saved file.
81
+
82
+ `npx` downloads the latest release on first run, verifies its checksum and caches it. Later runs start from the cache and pick up new releases automatically.
83
+
84
+ ## Renewing the cookie
85
+
86
+ The `aa_account_id2` cookie expires every week. When searches start failing with `[UPSTREAM_BLOCKED]`:
87
+
88
+ 1. Copy a fresh `aa_account_id2` value from your browser, as in [step 1](#1-get-your-credentials).
89
+ 2. Replace `ANNAS_ACCOUNT_COOKIE` in your client's config.
90
+ 3. Restart the MCP server from your client.
91
+
92
+ `ANNAS_ACCOUNT_COOKIE` accepts the bare value, `aa_account_id2=…` or a whole `Cookie` header copied from the Network tab. Only `aa_account_id2` is ever sent. Keep the cookie and API key private.
93
+
94
+ ## Configuration
95
+
96
+ | Variable | Needed for | Description |
97
+ | --- | --- | --- |
98
+ | `ANNAS_ACCOUNT_COOKIE` | Everything | Your `aa_account_id2` cookie. |
99
+ | `ANNAS_SECRET_KEY` | Downloads | Your Anna's Archive API key. |
100
+ | `ANNAS_DOWNLOAD_PATH` | Downloads | Absolute folder for downloaded files. Created if missing. |
101
+ | `ANNAS_BASE_URL` | Optional | Mirror to fall back on when automatic selection fails. Defaults to `annas-archive.gl`. |
102
+ | `ANNAS_AUTO_BASE_URL` | Optional | Set to `false` to always use `ANNAS_BASE_URL` instead of picking a mirror automatically. |
103
+ | `ANNAS_MCP_CACHE_DIR` | Optional | Where `npx` caches release binaries. |
104
+
105
+ The server picks a working mirror by itself, and only among the official domains `annas-archive.gl`, `.pk` and `.gd`. Whatever host you set in `ANNAS_BASE_URL` receives your credentials, so only use one you trust.
106
+
107
+ The server and CLI also read a `.env` file from their working directory. Variables already set in the environment take precedence.
108
+
109
+ ## Tools
110
+
111
+ Every tool accepts `timeout_seconds`, covering the whole operation including retries. Searches default to 60 seconds and downloads to 30 minutes.
112
+
113
+ ### Searching
114
+
115
+ `book_search` and `article_search` take a `query` and return one page of results. Each result includes a `hash`, title, authors, format, size and language, and books also include a publisher. Optional fields:
116
+
117
+ | Field | Default | Meaning |
118
+ | --- | --- | --- |
119
+ | `limit` | 10 | Results to return from the page. Raise this before asking for another page. |
120
+ | `page` | 1 | Result page. |
121
+ | `language` | Any | Two-letter code such as `en`. |
122
+ | `content` | Any | `book_fiction`, `book_nonfiction`, `book_unknown`, `book_comic`, `magazine` or `standards_document`. |
123
+
124
+ Give `article_search` a DOI (`10.1038/nature14539`, `doi:…` or `https://doi.org/…`) to get that one article instead of a page of results.
125
+
126
+ ### Downloading
127
+
128
+ `book_download` takes the `hash` and a `title`. `article_download` takes either a `doi` or a `hash`, plus an optional `title`. Both return the saved file's `path` and size in `bytes`.
129
+
130
+ - **Filename.** `title` becomes the filename. Pass `format` (such as `epub` or `pdf`) to set the extension; this doesn't convert the file. Without it, the extension is detected from the download.
131
+ - **No overwrites.** An existing file is never overwritten: a name clash gets a short suffix.
132
+ - **Integrity.** Files are checked against the archive's MD5 hash, and incomplete or corrupt downloads are deleted.
133
+ - **DOI fallback.** When the fast download servers don't have a paper, `article_download` by DOI falls back to the PDF on Anna's SciDB page.
134
+
135
+ ## Troubleshooting
136
+
137
+ Errors start with a code:
138
+
139
+ | Code | What to do |
140
+ | --- | --- |
141
+ | `[CONFIG]` | A variable is missing or invalid. Check [Configuration](#configuration). |
142
+ | `[UPSTREAM_BLOCKED]` | The archive refused access. Usually the cookie has expired: [renew it](#renewing-the-cookie). Also check your membership tier. |
143
+ | `[NOT_FOUND]` | Nothing matched, or that copy has no fast download. Try another query, or another copy from the search results. |
144
+ | `[INVALID_ARGUMENT]` | Fix the hash, DOI, format, page, limit or timeout. |
145
+ | `[REQUEST_TIMEOUT]` | Retry, or raise `timeout_seconds`. |
146
+ | `[UPSTREAM]` | The archive or a download server failed. Check your daily download quota and API key, then retry. |
147
+
148
+ A failed download lists every server it tried and why each one failed. A server starting up cleanly doesn't prove your credentials work; only a search or download tests them.
149
+
150
+ If the server doesn't start at all, run `npx -y github:SokolskyNikita/annas-mcp --version` in a terminal to see the error. The launcher unpacks releases with the system's `tar`, which comes preinstalled on macOS, Windows 10 and later, and mainstream Linux distributions.
151
+
152
+ ## Command line
153
+
154
+ Every tool is also a command. Set the same variables (or put them in a `.env` file), then:
155
+
156
+ ```bash
157
+ npx -y github:SokolskyNikita/annas-mcp book-search "pride and prejudice" --language en
158
+ npx -y github:SokolskyNikita/annas-mcp article-search 10.1038/nature14539
159
+ npx -y github:SokolskyNikita/annas-mcp book-download <hash> "Pride and Prejudice.epub"
160
+ npx -y github:SokolskyNikita/annas-mcp article-download 10.1038/nature14539
161
+ ```
162
+
163
+ Add `--json` for machine-readable output, `--timeout 10m` to change the timeout, and `--help` to any command for all of its options.
164
+
165
+ ## Privacy
166
+
167
+ annas-mcp runs on your machine and has no telemetry. It sends your cookie and API key only to Anna's Archive, and downloads files from the servers Anna's Archive points it to. DOI lookups may contact doi.org. The `npx` launcher and the MCP Bundle contact GitHub to fetch and verify release binaries. Downloaded files stay in `ANNAS_DOWNLOAD_PATH`; nothing else is stored apart from the cached binary. Questions: [open an issue](https://github.com/SokolskyNikita/annas-mcp/issues).
168
+
169
+ ## Contributing
170
+
171
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for building from source, tests and releases.
172
+
173
+ ## Credits
174
+
175
+ The idea for this project comes from [iosifache/annas-mcp](https://github.com/iosifache/annas-mcp), which first exposed Anna's Archive as an MCP server. This repository is a complete rewrite and shares no code with it.
176
+
177
+ ## License
178
+
179
+ [MIT](LICENSE)
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { isDirectRun, main } from "../lib/launcher.js";
4
+
5
+ if (isDirectRun(import.meta.url)) {
6
+ main().catch((error) => {
7
+ console.error(error.message);
8
+ process.exit(1);
9
+ });
10
+ }
package/lib/archive.js ADDED
@@ -0,0 +1,102 @@
1
+ import { spawnSync as defaultSpawnSync } from "node:child_process";
2
+ import { readdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ function tarFailure(action, archive, result) {
6
+ if (result.error) {
7
+ return new Error(`Failed to run tar: ${result.error.message}`);
8
+ }
9
+ const detail = (result.stderr || result.stdout || "").trim();
10
+ return new Error(`Failed to ${action} ${path.basename(archive)}${detail ? `: ${detail}` : ""}`);
11
+ }
12
+
13
+ // Git Bash can put its GNU tar before the Windows native tar on PATH. GNU tar
14
+ // treats a drive-qualified path such as C:\\tmp\\archive.zip as a remote
15
+ // archive name, so use the native bsdtar explicitly on Windows.
16
+ export function tarExecutable(platform = process.platform, env = process.env) {
17
+ if (platform !== "win32") {
18
+ return "tar";
19
+ }
20
+ const systemRoot = env.SystemRoot || env.WINDIR || "C:\\Windows";
21
+ return path.win32.join(systemRoot, "System32", "tar.exe");
22
+ }
23
+
24
+ export function validateArchiveEntryName(rawName) {
25
+ const name = rawName.trim().replaceAll("\\", "/").replace(/\/+$/, "");
26
+ if (
27
+ !name ||
28
+ name.startsWith("/") ||
29
+ /^[A-Za-z]:\//.test(name) ||
30
+ name.split("/").includes("..")
31
+ ) {
32
+ throw new Error(`Refusing to extract an unsafe archive path: ${rawName}`);
33
+ }
34
+ return name;
35
+ }
36
+
37
+ export function validateArchiveEntries(archive, extension, spawnSync = defaultSpawnSync) {
38
+ const tar = tarExecutable();
39
+ const listingArgs = extension === "zip" ? ["-tf", archive] : ["-tJf", archive];
40
+ const listing = spawnSync(tar, listingArgs, { encoding: "utf8" });
41
+ if (listing.error || listing.status !== 0) {
42
+ throw tarFailure("inspect", archive, listing);
43
+ }
44
+ for (const rawName of listing.stdout.split(/\r?\n/)) {
45
+ if (rawName.trim()) {
46
+ validateArchiveEntryName(rawName);
47
+ }
48
+ }
49
+
50
+ const verboseArgs = extension === "zip" ? ["-tvf", archive] : ["-tvJf", archive];
51
+ const verbose = spawnSync(tar, verboseArgs, { encoding: "utf8" });
52
+ if (verbose.error || verbose.status !== 0) {
53
+ throw tarFailure("inspect", archive, verbose);
54
+ }
55
+ for (const line of verbose.stdout.split(/\r?\n/)) {
56
+ if (!line.trim()) {
57
+ continue;
58
+ }
59
+ const type = line[0];
60
+ if (type !== "-" && type !== "d") {
61
+ throw new Error(`Refusing non-file archive entry: ${line.trim()}`);
62
+ }
63
+ }
64
+ }
65
+
66
+ export function extractArchive(archive, destination, extension, spawnSync = defaultSpawnSync) {
67
+ validateArchiveEntries(archive, extension, spawnSync);
68
+ const tar = tarExecutable();
69
+ const args =
70
+ extension === "zip"
71
+ ? ["-xf", archive, "-C", destination]
72
+ : ["-xJf", archive, "-C", destination];
73
+ const result = spawnSync(tar, args, { encoding: "utf8" });
74
+ if (result.error || result.status !== 0) {
75
+ throw tarFailure("extract", archive, result);
76
+ }
77
+ }
78
+
79
+ export async function findBinary(directory, binaryName, root = directory) {
80
+ const entries = await readdir(directory, { withFileTypes: true });
81
+ for (const entry of entries) {
82
+ if (entry.isSymbolicLink()) {
83
+ continue;
84
+ }
85
+ const fullPath = path.join(directory, entry.name);
86
+ const relative = path.relative(root, fullPath);
87
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
88
+ throw new Error(`Refusing to use a path outside the archive: ${entry.name}`);
89
+ }
90
+ if (entry.isDirectory()) {
91
+ const found = await findBinary(fullPath, binaryName, root);
92
+ if (found) {
93
+ return found;
94
+ }
95
+ continue;
96
+ }
97
+ if (entry.isFile() && entry.name === binaryName) {
98
+ return fullPath;
99
+ }
100
+ }
101
+ return null;
102
+ }
@@ -0,0 +1,380 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { spawn as defaultSpawn } from "node:child_process";
3
+ import { createWriteStream, realpathSync } from "node:fs";
4
+ import {
5
+ chmod,
6
+ copyFile,
7
+ lstat,
8
+ mkdir,
9
+ mkdtemp,
10
+ readFile,
11
+ rename,
12
+ rm,
13
+ writeFile,
14
+ } from "node:fs/promises";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { Readable } from "node:stream";
18
+ import { pipeline } from "node:stream/promises";
19
+ import { fileURLToPath, pathToFileURL } from "node:url";
20
+ import { extractArchive, findBinary } from "./archive.js";
21
+ import {
22
+ checksumFor,
23
+ pathInside,
24
+ releaseAssetDigest,
25
+ goreleaserTarget,
26
+ safePathComponent,
27
+ selectAsset,
28
+ selectChecksumAsset,
29
+ sha256Digest,
30
+ } from "./target.js";
31
+
32
+ export const GITHUB_REPO = "SokolskyNikita/annas-mcp";
33
+ export const RELEASE_TIMEOUT_MS = 5_000;
34
+ export const DOWNLOAD_TIMEOUT_MS = 120_000;
35
+
36
+ export function releaseAPI(env = process.env) {
37
+ return (
38
+ env.ANNAS_MCP_RELEASE_API ||
39
+ `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`
40
+ );
41
+ }
42
+
43
+ export function cacheDir(
44
+ env = process.env,
45
+ platform = process.platform,
46
+ homeDirectory = os.homedir(),
47
+ ) {
48
+ if (env.ANNAS_MCP_CACHE_DIR) {
49
+ return env.ANNAS_MCP_CACHE_DIR;
50
+ }
51
+ const base =
52
+ platform === "win32"
53
+ ? env.LOCALAPPDATA || path.join(homeDirectory, "AppData", "Local")
54
+ : env.XDG_CACHE_HOME || path.join(homeDirectory, ".cache");
55
+ return path.join(base, "annas-mcp");
56
+ }
57
+
58
+ export function isDirectRun(moduleURL = import.meta.url, argv = process.argv) {
59
+ const entry = argv[1];
60
+ if (!entry) {
61
+ return false;
62
+ }
63
+ try {
64
+ // npx may invoke the bin through a symlink. Compare resolved file URLs.
65
+ const entryPath = realpathSync(entry);
66
+ const modulePath = realpathSync(fileURLToPath(moduleURL));
67
+ return pathToFileURL(entryPath).href === pathToFileURL(modulePath).href;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ export async function readCacheMeta(dir) {
74
+ try {
75
+ const meta = JSON.parse(await readFile(path.join(dir, "current.json"), "utf8"));
76
+ return meta && typeof meta === "object" && !Array.isArray(meta) ? meta : null;
77
+ } catch (error) {
78
+ if (error.code === "ENOENT" || error instanceof SyntaxError) {
79
+ return null;
80
+ }
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ export async function sha256File(file) {
86
+ const hash = createHash("sha256");
87
+ hash.update(await readFile(file));
88
+ return hash.digest("hex");
89
+ }
90
+
91
+ export async function cachedBinary(
92
+ dir,
93
+ release,
94
+ asset,
95
+ target,
96
+ meta = undefined,
97
+ expectedAssetSha256 = null,
98
+ ) {
99
+ const current = meta === undefined ? await readCacheMeta(dir) : meta;
100
+ if (
101
+ !current ||
102
+ current.tag !== release.tag_name ||
103
+ current.asset !== asset.name ||
104
+ (target &&
105
+ (current.target?.os !== target.os ||
106
+ current.target?.arch !== target.arch ||
107
+ current.target?.binaryName !== target.binaryName))
108
+ ) {
109
+ return null;
110
+ }
111
+
112
+ const binarySha256 = sha256Digest(current.binarySha256);
113
+ const assetSha256 = sha256Digest(current.assetSha256);
114
+ const expectedAssetDigest = expectedAssetSha256 || releaseAssetDigest(asset);
115
+ if (!binarySha256 || !assetSha256 || (expectedAssetDigest && assetSha256 !== expectedAssetDigest)) {
116
+ return null;
117
+ }
118
+
119
+ const binary = path.resolve(String(current.binary || ""));
120
+ if (!pathInside(dir, binary)) {
121
+ return null;
122
+ }
123
+
124
+ try {
125
+ const info = await lstat(binary);
126
+ if (!info.isFile() || info.isSymbolicLink()) {
127
+ return null;
128
+ }
129
+ if ((await sha256File(binary)) !== binarySha256) {
130
+ return null;
131
+ }
132
+ return binary;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ function getFetch(fetchImpl) {
139
+ const selected = fetchImpl || globalThis.fetch;
140
+ if (typeof selected !== "function") {
141
+ throw new Error("This Node.js runtime does not provide fetch");
142
+ }
143
+ return selected;
144
+ }
145
+
146
+ export async function fetchLatestRelease({
147
+ env = process.env,
148
+ fetchImpl = globalThis.fetch,
149
+ timeoutMs = RELEASE_TIMEOUT_MS,
150
+ } = {}) {
151
+ const fetcher = getFetch(fetchImpl);
152
+ const api = releaseAPI(env);
153
+ const response = await fetcher(api, {
154
+ headers: {
155
+ Accept: "application/vnd.github+json",
156
+ "User-Agent": "annas-mcp",
157
+ "X-GitHub-Api-Version": "2022-11-28",
158
+ },
159
+ signal: AbortSignal.timeout(timeoutMs),
160
+ });
161
+ if (!response.ok) {
162
+ const body = await response.text();
163
+ throw new Error(
164
+ `GitHub release request failed (${response.status}) for ${api}: ${body.slice(0, 200)}`,
165
+ );
166
+ }
167
+ const release = await response.json();
168
+ if (!release?.tag_name || !Array.isArray(release.assets)) {
169
+ throw new Error(`GitHub release response from ${api} is missing tag_name or assets`);
170
+ }
171
+ safePathComponent(release.tag_name, "release tag");
172
+ return release;
173
+ }
174
+
175
+ export async function downloadText(
176
+ url,
177
+ { fetchImpl = globalThis.fetch, timeoutMs = RELEASE_TIMEOUT_MS } = {},
178
+ ) {
179
+ const response = await getFetch(fetchImpl)(url, {
180
+ headers: { "User-Agent": "annas-mcp" },
181
+ signal: AbortSignal.timeout(timeoutMs),
182
+ redirect: "follow",
183
+ });
184
+ if (!response.ok) {
185
+ throw new Error(`Download failed (${response.status}) for ${url}`);
186
+ }
187
+ return response.text();
188
+ }
189
+
190
+ export async function downloadFile(
191
+ url,
192
+ destination,
193
+ { fetchImpl = globalThis.fetch, timeoutMs = DOWNLOAD_TIMEOUT_MS } = {},
194
+ ) {
195
+ const response = await getFetch(fetchImpl)(url, {
196
+ headers: { "User-Agent": "annas-mcp" },
197
+ signal: AbortSignal.timeout(timeoutMs),
198
+ redirect: "follow",
199
+ });
200
+ if (!response.ok || !response.body) {
201
+ throw new Error(`Download failed (${response.status}) for ${url}`);
202
+ }
203
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(destination));
204
+ }
205
+
206
+ export async function expectedArchiveChecksum(
207
+ release,
208
+ asset,
209
+ { env = process.env, fetchImpl = globalThis.fetch } = {},
210
+ ) {
211
+ const checksumAsset = selectChecksumAsset(release.assets);
212
+ const checksumText = await downloadText(checksumAsset.browser_download_url, { fetchImpl });
213
+ const expected = sha256Digest(checksumFor(checksumText, asset.name));
214
+ if (!expected) {
215
+ throw new Error(`Invalid checksum for ${asset.name}`);
216
+ }
217
+ const advertised = releaseAssetDigest(asset);
218
+ if (advertised && advertised !== expected) {
219
+ throw new Error(`Release digest disagrees with checksums for ${asset.name}`);
220
+ }
221
+ return expected;
222
+ }
223
+
224
+ export async function writeCacheMeta(dir, meta) {
225
+ const temporary = path.join(dir, `.current.json.${process.pid}.${randomUUID()}.tmp`);
226
+ try {
227
+ await writeFile(temporary, `${JSON.stringify(meta)}\n`);
228
+ await rename(temporary, path.join(dir, "current.json"));
229
+ } finally {
230
+ await rm(temporary, { force: true });
231
+ }
232
+ }
233
+
234
+ export async function installBinary(
235
+ release,
236
+ asset,
237
+ target,
238
+ expectedAssetSha256,
239
+ {
240
+ env = process.env,
241
+ fetchImpl = globalThis.fetch,
242
+ downloadFileImpl = downloadFile,
243
+ extractArchiveImpl = extractArchive,
244
+ findBinaryImpl = findBinary,
245
+ } = {},
246
+ ) {
247
+ const root = path.resolve(cacheDir(env));
248
+ const releaseTag = safePathComponent(release.tag_name, "release tag");
249
+ const assetName = safePathComponent(asset.name, "release asset name");
250
+ const expected = sha256Digest(expectedAssetSha256);
251
+ if (!expected) {
252
+ throw new Error(`Invalid checksum for ${assetName}`);
253
+ }
254
+ await mkdir(root, { recursive: true });
255
+ const work = await mkdtemp(path.join(root, ".tmp-"));
256
+ const archivePath = path.join(work, assetName);
257
+ try {
258
+ await downloadFileImpl(asset.browser_download_url, archivePath, { fetchImpl });
259
+ const actual = await sha256File(archivePath);
260
+ if (actual !== expected) {
261
+ throw new Error(`Checksum mismatch for ${assetName}`);
262
+ }
263
+ const extracted = path.join(work, "extracted");
264
+ await mkdir(extracted);
265
+ extractArchiveImpl(archivePath, extracted, target.extension);
266
+ const found = await findBinaryImpl(extracted, target.binaryName);
267
+ if (!found) {
268
+ throw new Error(`Archive ${assetName} does not contain ${target.binaryName}`);
269
+ }
270
+ const binarySha256 = await sha256File(found);
271
+ const binaryDir = path.join(root, "versions", releaseTag);
272
+ await mkdir(binaryDir, { recursive: true });
273
+ const binaryPath = path.join(binaryDir, `${actual}-${target.binaryName}`);
274
+ const temporary = `${binaryPath}.${process.pid}.${randomUUID()}.tmp`;
275
+ await copyFile(found, temporary);
276
+ await chmod(temporary, 0o755);
277
+ try {
278
+ await rename(temporary, binaryPath);
279
+ } catch (error) {
280
+ if (!["EEXIST", "EPERM"].includes(error.code)) {
281
+ throw error;
282
+ }
283
+ if ((await sha256File(binaryPath)) !== binarySha256) {
284
+ throw error;
285
+ }
286
+ await rm(temporary, { force: true });
287
+ }
288
+ const meta = {
289
+ tag: releaseTag,
290
+ asset: assetName,
291
+ assetSha256: actual,
292
+ binarySha256,
293
+ binary: binaryPath,
294
+ target: {
295
+ os: target.os,
296
+ arch: target.arch,
297
+ binaryName: target.binaryName,
298
+ },
299
+ };
300
+ await writeCacheMeta(root, meta);
301
+ return binaryPath;
302
+ } finally {
303
+ await rm(work, { recursive: true, force: true });
304
+ }
305
+ }
306
+
307
+ export async function resolveBinary(
308
+ target,
309
+ {
310
+ env = process.env,
311
+ fetchImpl = globalThis.fetch,
312
+ installBinaryImpl = installBinary,
313
+ } = {},
314
+ ) {
315
+ const dir = path.resolve(cacheDir(env));
316
+ const current = await readCacheMeta(dir);
317
+ let cached = null;
318
+ if (current) {
319
+ cached = await cachedBinary(
320
+ dir,
321
+ { tag_name: current.tag },
322
+ { name: current.asset },
323
+ target,
324
+ current,
325
+ );
326
+ }
327
+ try {
328
+ const release = await fetchLatestRelease({ env, fetchImpl });
329
+ const asset = selectAsset(release.assets, target);
330
+ const expectedAssetSha256 = await expectedArchiveChecksum(release, asset, { env, fetchImpl });
331
+ const currentRelease = await cachedBinary(
332
+ dir,
333
+ release,
334
+ asset,
335
+ target,
336
+ undefined,
337
+ expectedAssetSha256,
338
+ );
339
+ if (currentRelease) {
340
+ return currentRelease;
341
+ }
342
+ return await installBinaryImpl(release, asset, target, expectedAssetSha256, {
343
+ env,
344
+ fetchImpl,
345
+ });
346
+ } catch (error) {
347
+ if (cached) {
348
+ console.error(`annas-mcp: update check failed, using the cached binary. ${error.message}`);
349
+ return cached;
350
+ }
351
+ throw error;
352
+ }
353
+ }
354
+
355
+ export function runBinary(binary, args, { spawnImpl = defaultSpawn, processRef = process } = {}) {
356
+ const child = spawnImpl(binary, args, { stdio: "inherit" });
357
+ child.on("error", (error) => {
358
+ console.error(error.message);
359
+ processRef.exit(1);
360
+ });
361
+ for (const signal of ["SIGINT", "SIGTERM"]) {
362
+ processRef.on(signal, () => {
363
+ child.kill(signal);
364
+ });
365
+ }
366
+ child.on("exit", (code, signal) => {
367
+ if (signal) {
368
+ processRef.exit(1);
369
+ }
370
+ processRef.exit(code ?? 1);
371
+ });
372
+ return child;
373
+ }
374
+
375
+ export async function main({ argv = process.argv.slice(2), env = process.env } = {}) {
376
+ const target = goreleaserTarget();
377
+ const commandArgs = argv.length === 0 ? ["mcp"] : argv;
378
+ const binary = await resolveBinary(target, { env });
379
+ return runBinary(binary, commandArgs);
380
+ }
package/lib/target.js ADDED
@@ -0,0 +1,116 @@
1
+ import path from "node:path";
2
+
3
+ const SUPPORTED_TARGETS = new Set([
4
+ "darwin-amd64",
5
+ "darwin-arm64",
6
+ "linux-amd64",
7
+ "linux-arm64",
8
+ "linux-arm",
9
+ "windows-amd64",
10
+ "windows-arm64",
11
+ "freebsd-amd64",
12
+ ]);
13
+
14
+ export function goreleaserTarget(platform = process.platform, arch = process.arch) {
15
+ const osName = platform === "win32" ? "windows" : platform;
16
+ const archName = arch === "x64" ? "amd64" : arch;
17
+ const key = `${osName}-${archName}`;
18
+ if (!SUPPORTED_TARGETS.has(key)) {
19
+ throw new Error(
20
+ `Unsupported platform ${platform}/${arch}. Supported targets: ${[...SUPPORTED_TARGETS].join(", ")}`,
21
+ );
22
+ }
23
+ return {
24
+ os: osName,
25
+ arch: archName,
26
+ extension: osName === "windows" ? "zip" : "tar.xz",
27
+ binaryName: osName === "windows" ? "annas-mcp.exe" : "annas-mcp",
28
+ };
29
+ }
30
+
31
+ export function safePathComponent(value, label) {
32
+ if (
33
+ typeof value !== "string" ||
34
+ !value ||
35
+ value === "." ||
36
+ value === ".." ||
37
+ value.includes("\0") ||
38
+ value.includes("/") ||
39
+ value.includes("\\") ||
40
+ /[<>:"|?*]/.test(value) ||
41
+ path.basename(value) !== value
42
+ ) {
43
+ throw new Error(`Invalid ${label}`);
44
+ }
45
+ return value;
46
+ }
47
+
48
+ export function sha256Digest(value) {
49
+ if (typeof value !== "string") {
50
+ return null;
51
+ }
52
+ const match = value.trim().match(/^(?:sha256:)?([a-fA-F0-9]{64})$/);
53
+ return match ? match[1].toLowerCase() : null;
54
+ }
55
+
56
+ export function releaseAssetDigest(asset) {
57
+ return sha256Digest(asset?.digest);
58
+ }
59
+
60
+ export function pathInside(root, candidate) {
61
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
62
+ return relative && !relative.startsWith("..") && !path.isAbsolute(relative);
63
+ }
64
+
65
+ export function validateDownloadAsset(asset) {
66
+ safePathComponent(asset?.name, "release asset name");
67
+ if (typeof asset?.browser_download_url !== "string" || !asset.browser_download_url) {
68
+ throw new Error(`Release asset ${asset?.name || "(unnamed)"} has no download URL`);
69
+ }
70
+ return asset;
71
+ }
72
+
73
+ export function selectChecksumAsset(assets) {
74
+ const matches = (assets || []).filter(
75
+ (asset) => typeof asset?.name === "string" && asset.name.endsWith("--checksums.txt"),
76
+ );
77
+ if (matches.length !== 1) {
78
+ const names = (assets || []).map((asset) => asset?.name).filter(Boolean);
79
+ throw new Error(
80
+ `Expected one checksums asset, found ${matches.length}. Assets: ${names.join(", ") || "(none)"}`,
81
+ );
82
+ }
83
+ return validateDownloadAsset(matches[0]);
84
+ }
85
+
86
+ export function checksumFor(text, filename) {
87
+ const base = path.basename(filename);
88
+ for (const line of text.split(/\r?\n/)) {
89
+ const match = line.trim().match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
90
+ if (!match) {
91
+ continue;
92
+ }
93
+ const name = match[2].trim();
94
+ if (name === filename || name === base || path.basename(name) === base) {
95
+ return match[1].toLowerCase();
96
+ }
97
+ }
98
+ throw new Error(`Checksum file has no entry for ${filename}`);
99
+ }
100
+
101
+ export function selectAsset(assets, target) {
102
+ const suffix = `_${target.os}_${target.arch}.${target.extension}`;
103
+ const matches = (assets || []).filter(
104
+ (asset) =>
105
+ typeof asset?.name === "string" &&
106
+ asset.name.startsWith("annas-mcp_") &&
107
+ asset.name.endsWith(suffix),
108
+ );
109
+ if (matches.length !== 1) {
110
+ const names = (assets || []).map((asset) => asset?.name).filter(Boolean);
111
+ throw new Error(
112
+ `Expected one release asset ending in ${suffix}, found ${matches.length}. Assets: ${names.join(", ") || "(none)"}`,
113
+ );
114
+ }
115
+ return validateDownloadAsset(matches[0]);
116
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "annas-mcp",
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "description": "MCP server and CLI for searching and downloading books and articles from Anna's Archive",
6
+ "mcpName": "io.github.SokolskyNikita/annas-mcp",
7
+ "author": "Nikita Sokolsky",
8
+ "keywords": [
9
+ "mcp",
10
+ "mcp-server",
11
+ "model-context-protocol",
12
+ "annas-archive",
13
+ "books",
14
+ "papers",
15
+ "doi",
16
+ "claude",
17
+ "cursor",
18
+ "codex"
19
+ ],
20
+ "homepage": "https://github.com/SokolskyNikita/annas-mcp#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/SokolskyNikita/annas-mcp/issues"
23
+ },
24
+ "type": "module",
25
+ "scripts": {
26
+ "test": "node --test scripts/check-version.test.mjs scripts/release-metadata.test.mjs && node scripts/test-npx-launcher.mjs",
27
+ "check:version": "node scripts/check-version.mjs",
28
+ "coverage": "go test -race -coverprofile coverage.out ./... && node scripts/check-coverage.mjs coverage.out",
29
+ "smoke": "node scripts/smoke-mcp.mjs",
30
+ "pack:mcpb": "node scripts/pack-mcpb.mjs"
31
+ },
32
+ "bin": {
33
+ "annas-mcp": "bin/annas-mcp.js"
34
+ },
35
+ "files": [
36
+ "bin/",
37
+ "lib/"
38
+ ],
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/SokolskyNikita/annas-mcp.git"
42
+ },
43
+ "engines": {
44
+ "node": ">=18"
45
+ }
46
+ }