MCP server¶
The bot also speaks the Model Context Protocol, so an agent such as Claude Code or Claude Desktop can find a song, pick a copy and save it without Telegram. It drives the same pipeline as the bot: the same Spotify lookup, the same slskd search and ranking, the same lossless check, the same library, history and wishlist.
There are two ways to reach it:
- Over stdio,
python -m music_downloader mcpstarts a server on stdin and stdout for an agent on the same machine. It reads the same environment variables or.envas the bot. - Over HTTP, with
MCP_PORTset, the bot process also serves MCP over streamable HTTP athttp://<host>:<MCP_PORT>/mcp. Every request needsAuthorization: Bearer <MCP_TOKEN>. This server shares the bot's database, slskd client and wishlist checker.
Tools¶
Every tool returns a JSON object. Tracks and copies come back with short ids (t3fa9c1, c81d0e2) that the
next call takes; an id is valid for an hour.
| Tool | What it does | Example call |
|---|---|---|
resolve_track(query) |
Spotify candidates for a free-text query, each with a track id, artist, title, album, year and duration | resolve_track(query="Nancy Sinatra - Bang Bang") |
search_copies(track_id, profile="library", limit=10) |
Soulseek copies of that track, best first, each with a copy id, format, quality, quality tier, size, duration, source user, score, lossless and fits_cap (under TELEGRAM_MAX_UPLOAD_MB). profile="library" puts lossless first; "chat" ranks for sound per megabyte. Searches "artist title", then the title alone |
search_copies(track_id="t3fa9c1", profile="library", limit=5) |
download(copy_id, deliver="library") |
Downloads the copy and waits for it, up to DOWNLOAD_TIMEOUT_SECS, sending progress notifications. deliver="library" renames it, embeds the cover, moves it into OUTPUT_DIR and records it in the history; "path" leaves the file in DOWNLOAD_DIR and returns its path. Both return the lossless check verdict (FLAC, WAV and AIFF; null otherwise) |
download(copy_id="c81d0e2", deliver="library") |
album_listing(copy_id) |
The audio files of the peer folder the copy came from, usually its whole release: name, format, quality, size and duration of each, the total size and the formats present. answered is false, with a reason (no_answer, unreachable), when the peer is offline or does not answer within 30 s; no_audio when the folder holds no audio |
album_listing(copy_id="c81d0e2") |
album_download(copy_id, deliver="library") |
Downloads every audio file of that folder (one slskd request for the lot), with progress per file. deliver="library" saves each file into OUTPUT_DIR as it lands, named from its tags (or its file name when the tags are missing), with the album's Spotify cover and a history row noted album; a file the library already has under that name is skipped (skipped true, path the library's file) and its download deleted. "path" leaves the files in DOWNLOAD_DIR. A file gives up when its transfer moves no byte for DOWNLOAD_TIMEOUT_SECS; the whole album waits up to ALBUM_TIMEOUT_SECS. Returns one outcome per file (ok, path, skipped, error, state) and the counts; a failed file never stops the others |
album_download(copy_id="c81d0e2", deliver="library") |
history(limit=20) |
The latest downloads, newest first, with their status (success, delivered, failed...) |
history(limit=10) |
library_has(artist, title) |
Whether the library holds a file that looks like this track (the bot's duplicate check), with the closest matches | library_has(artist="Nancy Sinatra", title="Bang Bang") |
wishlist_add(track_id, wanted="any") |
Searches the track again every WISHLIST_CHECK_HOURS. "any" waits for any copy; "better" waits for a copy above the best one search_copies found for that track |
wishlist_add(track_id="t3fa9c1", wanted="better") |
wishlist_list() |
Every wish, from every chat | wishlist_list() |
wishlist_remove(id) |
Removes a wish by the id wishlist_list shows |
wishlist_remove(id=4) |
status() |
Whether slskd answers, downloads waiting on a Save, Reject or Retry button in Telegram, MCP downloads running, the number of wishes, the upload cap and the version | status() |
A typical chain: resolve_track → search_copies with the track id → download with the copy id → history.
For the whole release: album_listing with the same copy id, then album_download.
Errors come in two shapes. A bad argument, an expired id or a refused wish is a tool error (is_error true)
with a one-line reason. A download that ran and failed is a normal result with ok false, the error and the
slskd state. wishlist_add for a track the owner already waits for returns that wish with
already_waiting true instead of adding a second one.
A wish added over MCP belongs to the owner, the first id in TELEGRAM_ALLOWED_USERS. The bot's wishlist
checker announces it in that private chat, and with /auto on there it fetches the copy as an auto search
does. The stdio server runs no checker: its wishes wait for the bot, which reads the same database when both
use the same DATA_DIR.
An album_download whose peer cannot be listed returns ok false with the listing's reason and no job.
An album the stdio server runs is its own: a bot restart on the same DATA_DIR does not take it over. If the
stdio server stops mid-album, nothing saves the files that land after it; the hourly sweep removes them.
A file left with deliver="path" stays in DOWNLOAD_DIR until the sweep removes it after
DOWNLOAD_CLEANUP_HOURS, so copy it out before then.
Connect over stdio¶
For Claude Code, add this to .mcp.json in your project (or run claude mcp add); Claude Desktop takes the
same block under mcpServers in claude_desktop_config.json. Point command at a Python that has the
package installed, from pip install telegram-slskd-local-bot or the .venv of a checkout.
{
"mcpServers": {
"slskd": {
"command": "python",
"args": ["-m", "music_downloader", "mcp"],
"env": {
"TELEGRAM_ALLOWED_USERS": "123456789",
"SPOTIFY_CLIENT_ID": "...",
"SPOTIFY_CLIENT_SECRET": "...",
"SLSKD_HOST": "http://192.168.1.100:5030",
"SLSKD_API_KEY": "...",
"DOWNLOAD_DIR": "/path/to/slskd/downloads",
"OUTPUT_DIR": "/path/to/music",
"DATA_DIR": "/path/to/data"
}
}
}
}
The stdio server never talks to Telegram, so it needs no TELEGRAM_BOT_TOKEN. Logs go to stderr.
Connect over HTTP¶
Set a port and a token in .env, uncomment the ports block in
docker-compose.yml and
recreate the container.
Then point the client at the URL with the token. For Claude Code:
claude mcp add --transport http slskd http://192.168.1.10:8765/mcp \
--header "Authorization: Bearer <MCP_TOKEN>"
or in .mcp.json:
{
"mcpServers": {
"slskd": {
"type": "http",
"url": "http://192.168.1.10:8765/mcp",
"headers": { "Authorization": "Bearer <MCP_TOKEN>" }
}
}
}
A request without the right token gets 401.
Security¶
- Keep it on your LAN. The HTTP server has no TLS. Publish the port on the host's LAN address (or reach it over a VPN), never on a public interface.
- The token is required. The bot refuses to start with
MCP_PORTset andMCP_TOKENempty, and compares the token in constant time. Anyone with the token can download into your library. - No Telegram rules apply. The MCP caller is the owner:
TELEGRAM_ALLOWED_USERSdoes not gate it,TELEGRAM_CHAT_DELIVERY_USERSdoes not lock it to chat delivery, and it sees and removes every chat's wishes. Give the token only to agents you would give your library to.