Youtube Player MCP
An MCP server and media playback service that lets an AI assistant play music on a real speaker. Ask your assistant to "play some lo-fi", "queue the next three tracks from that album", or "add this song to my workout playlist", and the audio plays from the machine running the service, including over Bluetooth.
Documentation
yt-player
An MCP server and media playback service that lets an AI assistant play music on a real speaker. Ask your assistant to "play some lo-fi", "queue the next three tracks from that album", or "add this song to my workout playlist", and the audio plays from the machine running the service, including over Bluetooth.
It has two parts, both started by the yt-player command:
- Playback service (
yt-player serve): a FastAPI app that streams audio with yt-dlp and ffplay. It manages a queue, playlists, and playback history, and stores them in SQLite. It also serves a REST API and a WebSocket event stream, so other clients can use it as well. - MCP server (
yt-player mcp, oryt-player-mcp): a thin MCP layer over the service's HTTP API. It supports stdio, streamable HTTP, and SSE transports.
The MCP server is a lightweight install that only depends on mcp and httpx. It can run on a different machine from the playback service.
YouTube is the first supported source. The API models are source-neutral: clients work with tracks, playback state, queues, playlists, and playback commands.
Requirements
The playback service plays audio on the machine it runs on.
| Platform | How to run the playback service | Audio |
|---|---|---|
| Linux | Docker Compose (recommended) or yt-player serve | PulseAudio, PipeWire, ALSA, or JACK |
| macOS | yt-player serve (not Docker) | CoreAudio |
| Windows | Not supported; use WSL or a Linux machine |
To run it without Docker you need Python 3.10+, ffmpeg (which provides ffplay), and a JavaScript runtime. They can be on the PATH, or you can give their locations with FFPLAY_PATH and JS_RUNTIME. YouTube requires yt-dlp to solve a JavaScript challenge. Any of these runtimes works:
| Runtime | Minimum version | JS_RUNTIME |
|---|---|---|
| Deno (default, recommended) | 2.3.0 | deno |
| Node.js | 22.0.0 | node |
| Bun | 1.2.11 | bun |
| QuickJS | 2023-12-09 | quickjs |
Optional tools:
pactlchanges the volume of a playing track without interrupting it. Without it, a volume change restarts the stream at the current position, which causes a short gap.bluetoothctl(BlueZ, Linux only) lets the service connect a Bluetooth speaker itself.
Docker Desktop on macOS and Windows can't pass host audio through to containers, so use Docker only on Linux. Any machine can run the MCP server and control a playback service running elsewhere (see Controlling a remote player).
The MCP server needs Python 3.10+ and network access to the playback service.
Quick start
-
Clone the repository and create your settings file:
git clone https://github.com/karunstha/yt-player.git cd yt-player cp .env.example .env mkdir -p dataCreate
data/yourself so you own it. If Docker creates it, it belongs to root. -
Edit
.env. By default the container plays through your PulseAudio or PipeWire socket.PULSE_RUNTIME_DIRmust point at/run/user/<UID>/pulse; runid -uto find your UID. To choose a speaker or use ALSA instead, see Audio output. -
Optional but recommended: add YouTube cookies, which make it less likely that YouTube blocks requests as a bot. Export your cookies in Netscape format (for example with a "cookies.txt" browser extension) and save them as
data/cookies.txt. The service runs without them. -
Start the playback service:
docker compose up -d -
Check that it responds:
curl http://127.0.0.1:5454/player/state -
Connect an MCP client using one of the options below.
Running without Docker
On Linux or macOS, with the system tools listed under Requirements:
pip install "yt-player[server] @ git+https://github.com/karunstha/yt-player"
yt-player serve
Without Docker, the service binds to 127.0.0.1:5454, stores data in ~/.local/share/yt-player, and plays through the system's default audio output. Use --host/--port or the configuration variables to change that, either in the environment or in a .env file in the directory you start it from. On macOS, install the system tools first with brew install ffmpeg deno. If you already have Node.js 22+, you can skip Deno and set JS_RUNTIME=node.
At startup the service prints the settings it is using and warns about anything that would stop playback, such as a missing ffplay or JavaScript runtime. Services started by launchd or systemd get a minimal PATH that usually excludes Homebrew and nvm. In that case, set FFPLAY_PATH and JS_RUNTIME to full paths, for example FFPLAY_PATH=/opt/homebrew/bin/ffplay.
Audio output
By default, audio goes to the system's default output device. Two settings change that, in .env or the environment:
AUDIO_DRIVERchooses the sound system:pulseaudio,pipewire,alsa,jack, orcoreaudio. If unset, the most suitable one is chosen automatically. Docker Compose setspulseaudio.AUDIO_DEVICEchooses the output device on that sound system.
| Sound system | AUDIO_DRIVER | AUDIO_DEVICE | List devices with |
|---|---|---|---|
| PulseAudio | pulseaudio | Sink name, e.g. alsa_output.usb-DAC-00.analog-stereo | pactl list short sinks |
| PipeWire | pulseaudio (through pipewire-pulse) | Sink name, as above | pactl list short sinks |
| ALSA | alsa | Device, e.g. plughw:1,0 | aplay -l |
| JACK, CoreAudio, native PipeWire | as named | Not supported; uses the system default |
Bluetooth speakers. DEFAULT_BLUETOOTH_DEVICE_ID only connects the speaker, before startup and before each track. To make sure audio actually goes to it, also set AUDIO_DEVICE to its sink, for example bluez_output.AA_BB_CC_DD_EE_FF.1 on PipeWire or bluez_sink.AA_BB_CC_DD_EE_FF.a2dp_sink on PulseAudio. The exact name appears in pactl list short sinks while the speaker is connected.
ALSA in Docker. On hosts without PulseAudio or PipeWire, set these in .env:
AUDIO_DRIVER=alsa
AUDIO_DEVICE=plughw:1,0
ALSA needs exclusive access to the sound card, so playback fails with "device busy" if a sound server on the host already uses that card. Compose still mounts PULSE_RUNTIME_DIR, so on a host without PulseAudio, Docker creates an empty /run/user/<UID>/pulse directory. It's safe to ignore.
Audio errors from ffplay appear in the service log (docker compose logs yt-player). Audio target '<name>' not available means ffplay was built without that sound system; pick another AUDIO_DRIVER or leave it empty.
Connecting an MCP client
Option A: stdio (recommended for desktop clients)
Your MCP client launches the server as a local process. The simplest way is uv, which installs it on first run without a separate setup step.
Claude Code:
claude mcp add yt-player \
-e MEDIA_SERVICE_URL=http://127.0.0.1:5454 \
-- uvx --from git+https://github.com/karunstha/yt-player yt-player-mcp
Claude Desktop, Cursor, and other JSON-configured clients:
{
"mcpServers": {
"yt-player": {
"command": "uvx",
"args": ["--from", "git+https://github.com/karunstha/yt-player", "yt-player-mcp"],
"env": {
"MEDIA_SERVICE_URL": "http://127.0.0.1:5454"
}
}
}
}
Without uv, install the package and point your client at the yt-player-mcp command:
pipx install git+https://github.com/karunstha/yt-player
If your client can run Docker commands, you can use the image you already built instead:
docker run --rm -i --network host \
-e MEDIA_SERVICE_URL=http://127.0.0.1:5454 \
yt-player yt-player mcp
Option B: streamable HTTP (long-running, Dockerized)
Start the MCP server alongside the playback service with the optional Compose profile:
docker compose --profile mcp up -d
The server then listens at:
http://127.0.0.1:5455/mcp
For Claude Code:
claude mcp add --transport http yt-player http://127.0.0.1:5455/mcp
Outside Docker, the same server runs with yt-player mcp --transport streamable-http.
Controlling a remote player
The MCP server only makes HTTP calls to the playback service, so it can run on a different machine from the speaker. For example, the playback service can run on a Linux mini PC connected to your speakers while your assistant runs on a laptop. Point MEDIA_SERVICE_URL at the player:
MEDIA_SERVICE_URL=http://media-box.local:5454
Read Security before you expose either port on a network.
MCP tools
| Area | Tools |
|---|---|
| Status | health, get_playback_state, get_current_track |
| Search | search_media |
| Playback | play_media, pause_playback, resume_playback, stop_playback, seek_playback, next_track, previous_track, set_volume, set_shuffle, set_repeat |
| History | get_playback_history (paginated with next_before) |
| Queue | get_queue, append_queue_track, insert_queue_track_next, queue_playlist, remove_queue_item, clear_queue, replace_queue_with_playlist |
| Playlists | list_playlists, get_playlist, create_playlist, rename_playlist, delete_playlist, add_track_to_playlist, add_tracks_to_playlist, add_current_track_to_playlist, move_playlist_track, remove_track_from_playlist, clear_playlist, play_playlist |
| Saving the queue | save_queue_as_playlist, add_queue_to_playlist |
| Import/export | export_playlists, import_playlists |
Tools that take a track (play_media, append_queue_track, insert_queue_track_next, add_track_to_playlist) accept either a search query or a url.
Searches and queries take an optional search_type that sets what to look for:
search_type | Searches | Example |
|---|---|---|
songs (default) | YouTube Music songs | "play Bohemian Rhapsody" |
music_videos | YouTube Music videos | "play the official video for…" |
podcasts | YouTube Music podcast episodes | "play the Lex Fridman episode with Sam Altman" |
videos | All of YouTube | tutorials, talks, live streams |
Change the default with DEFAULT_SEARCH_TYPE. add_tracks_to_playlist takes a list where each entry is a URL or a search query.
A playlist holds each track once. Adding a track that is already there fails with a message saying so. Batch adds (add_tracks_to_playlist, save_queue_as_playlist, add_queue_to_playlist) skip duplicates and list them in the result as skipped. Tracks that couldn't be found are listed as failed. Playlists can be referred to by ID, name, or a slug of the name.
Troubleshooting
- The service starts with warnings. Fix what they name: install the missing tool, or point
FFPLAY_PATHorJS_RUNTIMEat it. - Playback state shows
error, or nothing plays. Check the service log (docker compose logs yt-player, or the terminal runningyt-player serve). Errors from yt-dlp and ffplay appear there. For audio problems, see Audio output. - YouTube says "Sign in to confirm you're not a bot". Add cookies as
cookies.txtin the data directory (see Quick start). - The assistant reports "Could not reach the media service". The playback service isn't running, or
MEDIA_SERVICE_URLin your MCP client config points at the wrong host or port.
Security
Neither the playback service nor the MCP server has authentication.
- Under Docker Compose the playback service binds to
0.0.0.0:5454with host networking, so anyone on your network can control playback. SetSERVICE_HOST=127.0.0.1in.envif only this machine needs access. Otherwise, run it only on a trusted network, or put it behind a firewall or an authenticating reverse proxy. Outside Docker,yt-player servebinds to127.0.0.1by default. - The MCP HTTP transport binds to
127.0.0.1by default. KeepMCP_HOST=127.0.0.1unless you intentionally want other machines to control playback. If you bind it to0.0.0.0, protect the port the same way. - The playback container runs with
privileged: trueand mounts the host's D-Bus and Bluetooth state so it can manage Bluetooth devices. Remove those settings if you don't need Bluetooth. cookies.txtholds your YouTube session. Thedata/directory and.envare listed in.gitignore; never commit them.
Configuration
Both parts read environment variables. yt-player serve also loads a .env file from the directory it is started in. The MCP server does not read .env; set its variables in your MCP client's config, as in the examples above.
Empty values (such as SERVICE_PORT=) use the default. Invalid values stop startup with a message naming the setting.
Docker Compose (set in .env, see .env.example):
PULSE_RUNTIME_DIR: host PulseAudio directory mounted into the container. Defaults to/run/user/1000/pulse.DATA_PATH: host directory mounted at/data. Defaults to./data.SERVICE_HOST,SERVICE_PORT,AUDIO_DRIVER,AUDIO_DEVICE,DEFAULT_BLUETOOTH_DEVICE_ID,DEFAULT_VOLUME,DEFAULT_SEARCH_TYPE,MCP_HOST,MCP_PORT: passed through to the containers (see below). Under Compose,SERVICE_HOSTdefaults to0.0.0.0andAUDIO_DRIVERtopulseaudio.
Playback service:
SERVICE_HOST: bind host. Defaults to127.0.0.1.SERVICE_PORT: bind port. Defaults to5454.DATA_DIR: directory for persistent service data. Defaults to$XDG_DATA_HOME/yt-player(usually~/.local/share/yt-player); the Docker image uses/data.DATABASE_PATH: SQLite database path. Defaults to$DATA_DIR/media_service.sqlite3.COOKIES_FILE: Netscape-format cookies file passed to yt-dlp, used only if it exists. Defaults to$DATA_DIR/cookies.txt.AUDIO_DRIVER: sound system used for playback. Empty picks one automatically. See Audio output.AUDIO_DEVICE: output device on that sound system. Empty uses the system default.DEFAULT_BLUETOOTH_DEVICE_ID: optional Bluetooth device MAC address to connect on startup and before playback.DEFAULT_VOLUME: startup volume from0.0to1.0. Defaults to0.8.FFPLAY_PATH: theffplayexecutable. Defaults toffplayon the PATH.DEFAULT_SEARCH_TYPE: what searches look for when a request doesn't say:songs,music_videos,podcasts, orvideos. Defaults tosongs.JS_RUNTIME: JavaScript runtime yt-dlp uses for YouTube's challenge:deno,node,bun, orquickjs. Add a path asnode:/usr/local/bin/nodeif it isn't on the PATH. Give several, comma-separated, to fall back in order of yt-dlp's priority (deno, node, quickjs, bun). Defaults todeno. The Docker image ships Deno only.YTDLP_REMOTE_COMPONENTS: what yt-dlp may download if the bundled challenge solver (yt-dlp-ejs) is missing or doesn't match the installed yt-dlp version:ejs:github,ejs:npm, ornone. Defaults toejs:github. Normally nothing is downloaded, because theserverextra installs the matching solver.PLAYBACK_COMPLETION_GRACE_SECONDS: failed backend exits within this many seconds of track end are treated as completed. Defaults to5.PLAYBACK_RECOVERY_RETRIES: automatic stream restarts per track after a non-clean backend exit. Defaults to2.
MCP server:
MEDIA_SERVICE_URL: base URL of the playback service. Defaults tohttp://127.0.0.1:5454.MEDIA_SERVICE_TIMEOUT_SECONDS: HTTP timeout for calls to the playback service. Defaults to30.MCP_TRANSPORT:stdio,streamable-http, orsse. Defaults tostdio.MCP_HOST: bind host for HTTP transports. Defaults to127.0.0.1.MCP_PORT: bind port for HTTP transports. Defaults to5455.MCP_STREAMABLE_HTTP_PATH: streamable HTTP path. Defaults to/mcp.MCP_SSE_PATH: SSE event path. Defaults to/sse.MCP_MESSAGE_PATH: SSE message path. Defaults to/messages/.
Docker Compose bind-mounts DATA_PATH (default ./data) at /data, so playlists, saved track metadata, playback history, and cookies survive container replacement.
HTTP API
The MCP server is one client of this API. You can also call it directly, or build your own UI on top of it.
Playback:
POST /player/playwith one ofurl,query, ortrackGET /player/stateGET /player/currentPOST /player/pausePOST /player/resumePOST /player/stopPOST /player/seekPOST /player/nextPOST /player/previousPOST /player/volumePOST /player/shufflePOST /player/repeatGET /player/history?days=7&before=<iso-date-time>&limit=100
Search:
GET /search?q=...&count=10&type=songs:typeissongs,music_videos,podcasts, orvideos- Requests that take a
query(/player/play,/queue,/queue/tracks,/playlists/{id}/tracks, andbulkitems) also acceptsearch_type
Queue:
GET /queuePOST /queueDELETE /queuePOST /queue/tracksPOST /queue/tracks/nextPOST /queue/playlists/{id}with optionalplay_nextandshuffle: adds a playlist's tracks without replacing the queueDELETE /queue/items/{item_id}
Playlists:
POST /playlistsGET /playlistsGET /playlists/{id}PATCH /playlists/{id}DELETE /playlists/{id}POST /playlists/{id}/tracksPOST /playlists/{id}/tracks/bulkwithitems(up to 50 tracks): returnsadded,skipped, andfailedPOST /playlists/{id}/tracks/queue: appends the current queue, returns the same shape asbulkPOST /playlists/{id}/tracks/currentDELETE /playlists/{id}/tracks/{track_id}POST /playlists/{id}/tracks/{track_id}/movewith a 0-basedpositionPOST /playlists/{id}/tracks/reorderDELETE /playlists/{id}/tracksPOST /playlists/{id}/playPOST /playlists/{id}/shuffleGET /playlists/exportGET /playlists/{id}/exportPOST /playlists/import
Realtime events:
WS /events
The WebSocket sends playback.sync on connect and then broadcasts meaningful events such as track.changed, playback.state_changed, playback.seeked, queue.changed, playlist.changed, and track.completed. While playing, it emits low-frequency playback.sync updates approximately every 3 seconds.
Legacy API
The original endpoints are still available:
POST /playPOST /stopGET /statusPOST /searchPOST /play-searchPOST /shuffle
Project layout
yt_player/cli.py: theyt-playercommand and itsserveandmcpmodes.yt_player/factory.py: FastAPI app creation, lifespan handling, service wiring, and error handlers.yt_player/env.py: environment variable parsing shared by the service and the MCP server.yt_player/core/: service configuration, yt-dlp options (ytdlp.py), structured service errors, and shared utilities.yt_player/player/: playback routes, queue routes, legacy playback routes, playback controller, queue service, ffplay/yt-dlp backend, and player/queue models.yt_player/playlists/: playlist routes, persistent playlist service, SQLite repository, and playlist models.yt_player/sources/: media source integrations. YouTube is currently implemented inyt_player/sources/youtube.py.yt_player/events/: WebSocket route, event broadcaster, and event models.yt_player/mcp/: MCP server and HTTP client wrapper for the media service API.main.py: ASGI entrypoint for running the app directly with Uvicorn (uvicorn main:app).
Development
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/python -m pytest