- TypeScript 82.4%
- Bru 9.8%
- Python 5.2%
- Shell 1.9%
- Dockerfile 0.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| bruno | ||
| sabnzbd | ||
| src | ||
| test | ||
| .containerignore | ||
| .env.example | ||
| .gitignore | ||
| compose.yml | ||
| Containerfile | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
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-26through2025-11-25, which current clients speak. It is stateless: every request gets a single JSON response, with no sessions and no SSE streams, soGETandDELETEreturn405. Clients that only speak the newer2026-07-28revision aren't supported yet. Clients that speak both fall back to2025-11-25automatically. - 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
Originheader) are rejected with403unless the origin is listed inMCP_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
}
eventisdownload.completedordownload.failed.idis the nzb-search id, andnzo_idmatches the onePOST /download/<id>returned.statusis SABnzbd's post-processing status:0OK,1verification failed,2unpack failed,3both,-1download failed.pathis the path inside the SABnzbd container./downloadsmaps toDOWNLOADS_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