Skip to main content
Glama
aramvr
by aramvr
README.md
# yandex-music-mcp

An MCP server that turns Yandex Music into tools an AI agent can call directly —
search, queue, play, build playlists — with **no browser involved**.

Audio comes out of your Mac's speakers via a small local player daemon. Claude
queues a whole DJ set in one call and the daemon advances through it on its own.
On macOS it also ships a native player: a real Now Playing entry in Control
Center, and a window with the full queue and per-track like/dislike.

> **Built with [Claude Code](https://claude.com/claude-code).** Design, code,
> debugging and this README were produced in conversation with Claude. Read it
> before you run it — it talks to your music account and writes a token to disk.

> **Unofficial.** This uses Yandex Music's private API. It is not affiliated
> with, endorsed by, or supported by Yandex. Endpoints can change without
> warning.

---

## Who this is for

Developers who want to clone a repo, build it, and mess about.

There is deliberately no installer, no Docker image, no one-click setup, and
that is a design decision rather than a to-do:

- **Audio cannot be containerized on macOS.** Docker Desktop runs containers in
  a Linux VM with no access to CoreAudio. A containerized player would run
  happily and produce silence. Routing audio to a network PulseAudio sink is
  possible but needs a server on the host and buys nothing on one machine.
- **The native UI is a compiled Swift `.app`** that has to be built and
  registered on the machine that will display it. Nothing to ship in an image.
- **The MCP server needs nothing kept running** — it is stdio, launched on
  demand. The only long-lived piece is the player daemon, and `launchd` is the
  native answer to that (`ym install-agent`).

So: clone it, build it, play with it. It is a couple of minutes.

---

## Why it exists

Driving music through a browser was miserable:

| | Browser | This |
|---|---|---|
| Login | separate session per browser profile, lost on tab close | one token, stored once |
| Autoplay | Chrome needs a real click before audio starts | n/a |
| Track changes | the agent wakes up between every track | daemon auto-advances |
| Token cost | ~3 tool calls per track | 1 call per **set** |
| Survives closing the tab | no | yes |

---

## Requirements

- **macOS** for playback and the native player. The API surface (search,
  playlists, resolving streams) is platform-agnostic; audio and UI are not.
- **Node 20+**
- **[mpv](https://mpv.io)** — `brew install mpv`. Without it the daemon falls
  back to `afplay`, which downloads each track before playing it.
- **Xcode command line tools** for the Swift helper — `xcode-select --install`.
- A **Yandex Music account with Plus**. Without Plus the API only returns
  30-second previews.

## Setup

```bash
git clone https://github.com/aramvr/yandex-music-mcp.git
cd yandex-music-mcp
pnpm install          # npm install works too
pnpm build
node dist/cli.js auth
```

Then point your agent at it:

```bash
claude mcp add yandex-music -- node "$PWD/dist/index.js"
```

Sanity check without an agent:

```bash
node dist/cli.js whoami
node dist/cli.js set "Rammstein - Sonne" "SOAD - Chop Suey" "Muse - Uprising"
node dist/cli.js player     # opens the window
```

---

## Logging in

`ym auth` opens Yandex's own authorize page, you approve, and you paste back
what lands in the address bar. That is the whole flow. The token is verified
against the music API *before* being written to `~/.yandex-music-mcp/.env` with
`0600` permissions, and a rejected token can never overwrite a working one.

**Your password never goes near this code.** Yandex issues the token to you in
the browser; the only thing that crosses is the token itself.

### Why there is no automatic login

Because it cannot work. Yandex publishes **no Yandex Music scope for
third-party OAuth apps**. Register your own app, wire up a perfectly correct
loopback flow, and the token you get back carries only
`landing-play, feed-play, mix-play` — the music API treats it as an anonymous
session with no uid and no login.

The only client id that can actually stream is Yandex's own music client, and
that one does not whitelist a `localhost` redirect. So the token has to travel
by clipboard. This was built the automatic way first; it does not work, and the
paste flow is what remains.

If a token ever does come back anonymous, `getAccount()` throws with the
permission list rather than silently pretending it worked.

Tokens are long-lived but not eternal. Run `ym auth` again when you see a 401;
`ym whoami` shows the expiry when it is known.

---

## Audio quality

Defaults to the best the account is entitled to. Two places quality can leak,
both closed.

**1. What gets requested.** `YM_QUALITY=lossless` (the default) tries the
`get-file-info` endpoint for FLAC first, then falls back to the
highest-bitrate MP3 rather than failing the track. `YM_QUALITY=high` skips
straight to MP3.

**In practice you will get MP3 320 CBR** — see below.

**2. What the player does with it.** mpv runs with every processing stage off:

| Flag | Why |
|---|---|
| `--replaygain=no` | no loudness normalisation |
| `--audio-normalize-downmix=no` | no downmix gain |
| `--audio-samplerate=0` | no resampling; the device follows the file |
| `--af=` | empty filter chain |
| `--volume-max=100` | soft volume above unity clips, so it is capped |
| `--cache=yes --demuxer-max-bytes=64MiB` | network FLAC stutters without buffer |

Volume is therefore 0–100, not 0–130. For more output, use the system volume.

**Verify rather than trust** — this reports the codec actually served:

```bash
node dist/cli.js quality "Rammstein Sonne"
```

`ym_now_playing` also shows a `Quality:` line, and every track logs its codec to
`~/.yandex-music-mcp/daemon.log`.

### Why not lossless

The web player's "superb" setting calls a different endpoint:

```
GET api.music.yandex.com/get-file-info/batch
      ?trackIds=…&quality=lossless
      &codecs=flac,aac,he-aac,mp3,flac-mp4,aac-mp4,he-aac-mp4
      &transports=encraw&sign=…
```

Two blockers, both intentional on Yandex's side:

1. **`sign`** is an HMAC whose key lives in the obfuscated web bundle.
   Recovering it means lifting a secret out of their client.
2. **`transports=encraw`** — the `enc` is *encrypted*. Even with a valid
   signature the stream needs decrypting.

Together that is DRM circumvention, so this project stops at the unencrypted
path. **That is a deliberate boundary, not a missing feature.** Don't "fix" it.

MP3 320 CBR is what the open path serves. If lossless matters to you, the
official clients do it properly.

---

## The macOS player

`pnpm build:native` compiles `native/NowPlaying.swift` into
`bin/Yandex Music.app` — a real `.app`, not a bare binary, and that detail
matters. Clicking the Now Playing widget asks LaunchServices to open the app
that owns the media session; a bare executable has no bundle to open, so macOS
walks up to the responsible process and opens **Terminal** instead.

The app is an `LSUIElement` agent — no Dock tile, no app-switcher entry — and it
does two jobs.

**It owns the Now Playing session.** mpv can publish one itself, but only from
the media file's own tags, and Yandex streams arrive with no ID3 at all, so
artist, album and artwork would simply be blank. The daemon runs mpv with
`--input-media-keys=no` so there is exactly one session, and pushes real
metadata into this helper instead.

Control Center's layout is fixed: artwork, title, artist, album, scrubber,
transport. It has no concept of a track list, and it does not draw like/dislike
even when a player registers `MPFeedbackCommand`. Which is the reason for the
second job.

**It draws the player window** — everything Control Center won't show. The full
queue with the current row marked, per-row like and dislike, double-click any
row to jump, a volume slider. Closing the window does not quit: the process
still owns the media session and the daemon's pipe.

Wire protocol is one JSON object per line over stdio, both directions:

```
daemon -> app   {"type":"track", title, artist, album, artworkUrl, durationSec,
                 elapsedSec, isPlaying, queueIndex, liked, disliked, volume}
                {"type":"queue", items:[{title, artist, durationSec, feedback}], queueIndex}
                clear | show
app -> daemon   {"command":"play"|"pause"|"toggle"|"next"|"prev"|"stop"}
                {"command":"seek","positionSec":N}
                {"command":"jump","index":N}
                {"command":"like"|"dislike","index":N?}   // index omitted = current
                {"command":"volume","percent":N}
```

Track updates fire every few seconds; the queue is pushed only when it changes.
Likes and dislikes go straight to the Yandex account and are remembered per
track id in the daemon's state file, so the hearts survive a restart.

Everything degrades to a no-op off macOS or when the app hasn't been built —
playback never depends on the UI being there.

---

## Tools

| Tool | What it does |
|---|---|
| `ym_auth_status` | verify token, report account + Plus state |
| `ym_auth_login` | explain how to log in (there is no automatic flow) |
| `ym_search` | search the catalogue |
| `ym_play_set` | **the main one** — queue a list of tracks and play through them |
| `ym_queue_add` | append without interrupting |
| `ym_play_now` | jump the queue with one track |
| `ym_control` | pause / resume / next / prev / stop |
| `ym_now_playing` | current track + position |
| `ym_open_player` | open the macOS player window |
| `ym_queue_show` | list the queue |
| `ym_volume` | 0–100 |
| `ym_playlist_create` | create a real playlist on the account |
| `ym_playlists` | list your playlists |
| `ym_like` | like the current or a named track |
| `ym_history` | what has been played |

`ym_play_set` takes free-text `"Artist - Title"` strings and resolves each by
search, preferring plausible-length originals over snippets and karaoke covers.
It also accepts `startAtIso` to begin a set later.

## CLI

Everything is reachable without an agent, which makes debugging easy:

```bash
ym search "rammstein sonne"
ym set "SOAD - Chop Suey" "Muse - Hysteria" "Rammstein - Sonne"
ym status
ym next
ym pause
ym player
```

---

## Architecture

```
Agent ──stdio──> MCP server (dist/index.js)     ephemeral, spawned per session
                       │
                       │ unix socket ~/.yandex-music-mcp/daemon.sock
                       ▼
                 Player daemon (dist/daemon.js)  long-lived, detached
                       │
                       ├── Yandex API (search, download-info → signed mp3 URL)
                       ├── mpv (IPC) or afplay (download-then-play fallback)
                       └── stdio ──> Yandex Music.app (Now Playing + window)
```

The split matters: the MCP server dies with the agent session, the daemon does
not. Queue state persists to `~/.yandex-music-mcp/state.json`, so a fresh agent
session sees whatever is already playing.

Auto-advance is driven by the backend's end-of-track event, not by polling — so
a 20-track set costs exactly one tool call.

One deliberate oddity: `src/http.ts` uses `node:https` rather than global
`fetch`. Node's fetch (undici) auto-injects browser-only headers — notably
`Sec-Fetch-Mode: cors` — which makes a server-side request look like a
malformed browser one, and Yandex's edge answers that with a 403 anti-bot page
instead of JSON. The identical request from `curl` or `node:https` succeeds.

### Auto-start at login

```bash
node dist/cli.js install-agent
```

Installs a launchd LaunchAgent (`dev.yandex-music-mcp.daemon`) that starts the
player daemon at login and restarts it if it dies. Remove with
`ym uninstall-agent`.

Optional — the MCP server spawns the daemon on demand if it isn't running.
Install the agent if you want music to survive restarts without a first call.

---

## Development

```bash
pnpm dev       # tsc --watch
pnpm test      # compiles, then node:test
```

Tests cover the two pure pieces worth locking down: the free-text track
resolver's ranking, and the token parser that decides whether a paste is
usable. Everything else needs a live account.

Layout:

```
src/
  index.ts       MCP server — tool definitions
  daemon.ts      player daemon — queue, auto-advance, state
  player.ts      mpv / afplay backends
  api.ts         Yandex API client
  http.ts        node:https wrapper (see above)
  auth.ts        token paste flow
  ipc.ts         unix-socket protocol + daemon autospawn
  config.ts      paths, env, token storage
  nowplaying.ts  Node side of the macOS bridge
native/
  NowPlaying.swift    the .app: Now Playing session + player window
scripts/
  build-native.sh     assembles the .app bundle
  make-icon.swift     draws the icon
```

---

## Caveats

- The private API can change without warning. If playback breaks, that is the
  first suspect — check `~/.yandex-music-mcp/daemon.log`.
- The MCP server is stdio-only and unauthenticated by design; it assumes it is
  launched by a local agent. Don't expose it.
- Your token grants full access to your Yandex account's music profile. It sits
  in `~/.yandex-music-mcp/.env` at mode `0600`. Treat it like a password.
- Personal listening on an account you pay for. Not for redistributing audio.

## License

[MIT](LICENSE).

Maintenance

ActivitySlowing
ResponsivenessNo issues