No description
  • TypeScript 82.4%
  • Bru 9.8%
  • Python 5.2%
  • Shell 1.9%
  • Dockerfile 0.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Adam Macumber cd04f4a46c Offer the API as an MCP server at /mcp
A stateless Streamable HTTP endpoint (MCP 2025-03-26..2025-11-25, JSON
responses only, no sessions) with search_nzbs and queue_download tools,
sharing the REST handlers' operations via src/nzbs.ts. Hand-rolled to keep
the project free of runtime dependencies. Browser origins must be listed
in MCP_ALLOWED_ORIGINS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:49:02 -04:00
bruno Offer the API as an MCP server at /mcp 2026-10-22 20:49:02 -04:00
sabnzbd Send NZBs to SABnzbd via compose and notify a webhook on completion 2026-10-22 20:35:59 -04:00
src Offer the API as an MCP server at /mcp 2026-10-22 20:49:02 -04:00
test Offer the API as an MCP server at /mcp 2026-10-22 20:49:02 -04:00
.containerignore Send NZBs to SABnzbd via compose and notify a webhook on completion 2026-10-22 20:35:59 -04:00
.env.example Offer the API as an MCP server at /mcp 2026-10-22 20:49:02 -04:00
.gitignore Send NZBs to SABnzbd via compose and notify a webhook on completion 2026-10-22 20:35:59 -04:00
compose.yml Send NZBs to SABnzbd via compose and notify a webhook on completion 2026-10-22 20:35:59 -04:00
Containerfile Initial commit 2026-10-22 17:14:12 -04:00
package-lock.json Initial commit 2026-10-22 17:14:12 -04:00
package.json Initial commit 2026-10-22 17:14:12 -04:00
README.md Offer the API as an MCP server at /mcp 2026-10-22 20:49:02 -04:00
tsconfig.json Initial commit 2026-10-22 17:14:12 -04:00

nzb-search

Small REST proxy in front of an upstream NZB indexer for downloading legally owned books. Runs TypeScript directly on Node 24 (type stripping), no runtime dependencies.

API

Method Path Description
GET /search?q=<query> Proxies to upstream search. Each result's upstream url (which embeds the API key) is removed and replaced with id and download (/download/<id>).
GET /download/<id> Returns the NZB as application/x-nzb.
POST /download/<id>?name=<name> Sends the NZB to SABnzbd and returns 202 {"id", "nzo_ids"}. name (optional) sets the job/folder name. When the download finishes, the webhook is notified (see Download webhook). 503 if SABnzbd isn't configured.
GET /healthz Liveness check.

Upstream and SABnzbd errors return 502 (404 if upstream says 404).

The same operations are also offered as an MCP server at /mcp.

MCP server

/mcp is a Model Context Protocol endpoint (Streamable HTTP) on the same port, so MCP clients such as Claude can search and queue downloads. It offers two tools:

Tool Description
search_nzbs(query) Same as GET /search. Each result has an id.
queue_download(id, name?) Same as POST /download/<id>. Returns SABnzbd's nzo_ids immediately; completion is reported to the download webhook, not to the MCP client. Only offered when SABnzbd is configured.

Connect a client, e.g. Claude Code:

claude mcp add --transport http nzb-search http://localhost:3000/mcp
  • Protocol: it implements MCP revisions 2025-03-26 through 2025-11-25, which current clients speak. It is stateless: every request gets a single JSON response, with no sessions and no SSE streams, so GET and DELETE return 405. Clients that only speak the newer 2026-07-28 revision aren't supported yet. Clients that speak both fall back to 2025-11-25 automatically.
  • No authentication, same as the REST API: anyone who can reach the port can search and queue downloads. Keep it on a trusted network.
  • Browser requests (those with an Origin header) are rejected with 403 unless the origin is listed in MCP_ALLOWED_ORIGINS. This guards against DNS rebinding.

Configuration

Set these in a .env file (copy .env.example) or as ordinary environment variables; real environment variables take precedence over .env.

Variable Default
UPSTREAM_BASE_URL — (required) e.g. https://upstream.com
UPSTREAM_API_KEY — (required) Sent as api_token
UPSTREAM_SEARCH_PATH /api/v2/search Called with query + api_token
UPSTREAM_DOWNLOAD_PATH /api/v2/download Called with id=<id>.nzb + api_token
UPSTREAM_TIMEOUT_MS 15000
PORT 3000 Under compose, the host port
SABNZBD_URL — e.g. http://localhost:8080. Unset disables POST /download/<id>. Set automatically under compose.
SABNZBD_API_KEY — (required with SABNZBD_URL) Any random string, e.g. openssl rand -hex 16. Compose seeds it into SABnzbd.
SABNZBD_SCRIPT webhook.py Post-processing script attached to each job
SABNZBD_CATEGORY — SABnzbd category for jobs (must exist in SABnzbd)
SABNZBD_TIMEOUT_MS 15000
WEBHOOK_URL — (required by compose) Read by SABnzbd's script, not by nzb-search
MCP_ALLOWED_ORIGINS — Comma-separated browser origins allowed to call /mcp

Development

npm install
npm test
npm run typecheck
cp .env.example .env   # then fill in UPSTREAM_API_KEY
npm start

Compose (with SABnzbd)

compose.yml runs nzb-search alongside SABnzbd (lscr.io/linuxserver/sabnzbd).

cp .env.example .env   # fill in UPSTREAM_API_KEY, SABNZBD_API_KEY, WEBHOOK_URL
docker compose up -d   # or: podman compose up -d

Then open SABnzbd at http://localhost:8080 and add your Usenet server under Config → Servers. That's the only manual step.

On first start, the sabnzbd-init service seeds sabnzbd.ini with your SABNZBD_API_KEY, adds sabnzbd to SABnzbd's hostname whitelist so nzb-search can reach it, and points the scripts folder at sabnzbd/scripts/. It never modifies an existing config. If you're reusing a SABnzbd config, set those three things in its UI yourself (Config → General → API Key, Config → Special → host_whitelist, Config → Folders → Scripts Folder = /scripts).

Downloads land in ./downloads/complete/<name>/ (change with DOWNLOADS_DIR). Other optional compose variables are PUID, PGID, TZ and SABNZBD_PORT.

Download webhook

Every job nzb-search queues runs sabnzbd/scripts/webhook.py when SABnzbd finishes it, after the job is downloaded, repaired and unpacked, and also when the job fails. The script POSTs JSON to WEBHOOK_URL:

{
  "event": "download.completed",
  "id": "151ba0d0-d220-4ebc-a578-5f4c380e94d3",
  "nzo_id": "b24e9a2a-b907-4c83-91f6-2f2b79eb8134",
  "name": "Some Book",
  "category": null,
  "status": 0,
  "fail_message": null,
  "path": "/downloads/complete/Some Book",
  "files": ["Some Book.epub"],
  "bytes": 2275317
}
  • event is download.completed or download.failed.
  • id is the nzb-search id, and nzo_id matches the one POST /download/<id> returned.
  • status is SABnzbd's post-processing status: 0 OK, 1 verification failed, 2 unpack failed, 3 both, -1 download failed.
  • path is the path inside the SABnzbd container. /downloads maps to DOWNLOADS_DIR.

The script retries 3 times. The outcome (Webhook notified …) shows in SABnzbd's history. To reach a service on the Docker host, use http://host.docker.internal:<port>/….

Container

podman build -t nzb-search .
podman run -d --name nzb-search -p 3000:3000 --env-file .env nzb-search

.env is never copied into the image (see .containerignore); it is passed in at run time.

Bruno collection

bruno/ is a Bruno collection covering every endpoint, with tests. Open the folder in Bruno and select the local environment (baseUrl, query). Run Search before Download and Send to SABnzbd: Search saves the first result's id for them to use. The MCP folder calls the MCP endpoint's initialize, tools/list and search_nzbs directly.

From the command line:

cd bruno
npx @usebruno/cli run -r --env local