Skip to main content
Glama
README.md
# spotify-mcp

[![Tests](https://github.com/Pacolias/spotify-mcp/actions/workflows/tests.yml/badge.svg)](https://github.com/Pacolias/spotify-mcp/actions/workflows/tests.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

An MCP (Model Context Protocol) server for interacting with the Spotify Web API, built with Python and FastAPI. Runs locally — an MCP host (Claude Desktop, Claude Code, etc.) launches it as a subprocess on your own machine.

This is a portfolio project by [Paco Molina](https://pacomolina.dev). The reasoning behind every architecture decision — and why — is logged in [`journal/`](journal/), one entry per decision, in the order they were made.

## Example

A real exchange with `import_youtube_playlist`, pulling new tracks from a YouTube mix's chapter list into an existing Spotify playlist (see [journal entry 24](journal/24-youtube-playlist-import.md) for the full story):

> **You:** Pull any new songs from this YouTube mix into my "Chill" playlist: youtube.com/watch?v=...
>
> **Claude:** *(calls `import_youtube_playlist`)* Found 14 tracklist entries in the video's chapters. 7 matched confidently on Spotify and were added; 7 were skipped — 4 pointed at the same wrong song (likely unreleased tracks not on Spotify), 2 didn't match any plausible artist, and 1 was a non-track marker ("LOOP"). Your playlist went from 3 to 10 tracks.

## How it works

[MCP](https://modelcontextprotocol.io/) is an open protocol that lets an AI client (like Claude) talk to external tools and data sources through a standard interface, instead of custom one-off integrations. A client connects to a server, and the server exposes three kinds of things: **tools** — functions the model actively decides to call, with a name, description, and typed schema generated from the function's signature and docstring; **resources** — read-only, URI-addressed data (e.g. `spotify://me/now-playing`) meant to be listed and attached to context more like a referenced document than an invoked action; and **prompts** — reusable prompt templates the host can surface directly to the user (often as a quick-access menu) that kick off a specific workflow with a pre-written message.

This project is two small, single-purpose local programs:

- **The MCP server itself** (`spotify_mcp.cli`, the `spotify-mcp` command), talking to its host over **stdio** — the host process launches it and communicates over stdin/stdout, no network involved. This is what an MCP-compatible client actually connects to.
- **A local login helper** (`spotify_mcp.main`, a small FastAPI app), run separately, that handles the Spotify OAuth2 (Authorization Code + PKCE) flow at `/auth/login` and `/auth/callback`. Spotify data (like "what's currently playing") is user-specific, so a logged-in user's access token is needed to call the Spotify API on their behalf. The login step needs a browser and an HTTP redirect regardless of how the MCP server itself talks to its host — that's a property of OAuth, not of MCP transport — so it's kept as its own small process rather than folded into the stdio one.

Once logged in, the access/refresh token pair is stored in a small SQLite database, shared by both processes, and transparently refreshed when it's close to expiring.

```mermaid
graph TD
    Host["MCP Host<br/>(e.g. Claude Desktop / Claude Code)"]
    Browser["User's Browser"]

    subgraph Stdio["MCP server process (stdio)"]
        MCPServer["mcp_server.run()<br/>stdio transport"]
        Tools["MCP tools<br/>search, now_playing, top_tracks, ..."]
        Resources["MCP resources<br/>spotify://me/now-playing, ..."]
        Prompts["MCP prompts<br/>build_playlist, listening_recap, ..."]
    end

    subgraph LoginApp["Login helper (FastAPI, run separately)"]
        AuthRoutes["/auth/login, /auth/callback<br/>(OAuth2 + PKCE)"]
    end

    SpotifyClient["Spotify client (httpx)"]
    DB[("SQLite<br/>spotify_mcp.db")]
    SpotifyAPI["api.spotify.com"]
    SpotifyAuth["accounts.spotify.com"]

    Host -->|"spawns as subprocess, stdin/stdout"| MCPServer
    MCPServer --> Tools
    MCPServer --> Resources
    MCPServer --> Prompts
    Tools --> SpotifyClient
    Resources --> SpotifyClient
    Prompts -.->|"guides toward"| Tools

    Browser --> AuthRoutes
    AuthRoutes -->|"redirect + code"| SpotifyAuth
    SpotifyAuth -->|"redirect back"| AuthRoutes
    AuthRoutes -->|"store token"| DB

    SpotifyClient -->|"read / refresh token"| DB
    SpotifyClient -->|"Authorization: Bearer access_token"| SpotifyAPI
```

### Available tools

| Tool | Description |
|---|---|
| `ping` | Health check — confirms the MCP server is reachable. |
| `search_track` | Search Spotify's catalog for tracks matching a query. |
| `now_playing` | Get the track currently playing on the logged-in user's account, if any. |
| `top_tracks` | Get the user's most-listened-to tracks (short/medium/long term). |
| `top_artists` | Get the user's most-listened-to artists (short/medium/long term). |
| `recently_played` | Get the user's most recently played tracks. |
| `list_user_playlists` | List the logged-in user's playlists. |
| `playlist_tracks` | List the tracks in a playlist. |
| `create_user_playlist` | Create a new playlist. ⚠️ `public=False` is currently ignored by a confirmed bug on Spotify's side — playlists are created public regardless. |
| `add_tracks` | Add one or more tracks to a playlist. |
| `remove_tracks` | Remove one or more tracks from a playlist. |
| `find_playlists` | Discover existing curated Spotify playlists matching a query (name/owner/description only — can't read tracks of playlists you don't own). |
| `import_youtube_playlist` | Build a new Spotify playlist from a YouTube video's tracklist (chapters/description — no audio recognition). ⚠️ Same `public=False` limitation as `create_user_playlist`. |
| `shuffle` | Turn shuffle mode on or off. |
| `repeat_mode` | Set repeat mode (track/context/off). |
| `seek` | Seek to a position in the currently playing track. |
| `liked_songs` | List the user's saved ("Liked Songs") tracks. |
| `like_tracks` | Save tracks to Liked Songs. ⚠️ requires Spotify's Extended Quota Mode — fails with a permissions error on apps without it (like this one, by default). |
| `unlike_tracks` | Remove tracks from Liked Songs. Same restriction as `like_tracks`. |
| `list_devices` | List Spotify devices already open (phone, desktop, web player, ...) and which is active. |
| `activate_device` | Switch playback to a specific device. Can't launch Spotify itself — only controls devices already running somewhere. |
| `pause` | Pause playback on the active device. |
| `resume` | Resume/start playback on the active device. |
| `skip_next` | Skip to the next track. |
| `skip_previous` | Skip to the previous track. |
| `set_playback_volume` | Set playback volume (0-100). |
| `queue_track` | Add a track to the playback queue. |

### Available resources

Read-only, returned as JSON.

| Resource | Description |
|---|---|
| `spotify://me/now-playing` | The track currently playing, if any. |
| `spotify://me/playlists` | The logged-in user's playlists. |
| `spotify://playlist/{playlist_id}` | The tracks in a specific playlist. |
| `spotify://me/profile` | Basic profile: id, display name, followers, URL/image. |
| `spotify://me/dashboard` | One-shot snapshot: now playing, devices, top tracks, recently played. |

### Available prompts

Quick-access templates a host can surface to kick off a workflow.

| Prompt | Description |
|---|---|
| `build_playlist` | Build a themed playlist using the model's own music knowledge (not generic search phrases). |
| `listening_recap` | A friendly summary of current listening, via the dashboard resource. |
| `import_youtube_mix` | Import a YouTube video's tracklist into a new playlist. |
| `start_listening` | Get music playing — check what's active, or help pick a device if nothing is. |
| `curate_from_liked` | Build a new playlist from a themed subset of the user's Liked Songs. |

## Setup

Requires Python 3.14+ and a Spotify account.

**1. Install dependencies** — pick one:

```bash
uv sync                             # with uv (recommended)
```
```bash
pip install -r requirements.txt     # or with plain pip — kept in sync with pyproject.toml via `uv export`
```

**2. Configure and log in:**

```bash
cp .env.example .env          # then set SPOTIFY_CLIENT_ID in .env — see below
uv run spotify-mcp login      # opens your browser, log in once
```

(Installed with plain `pip`? Drop the `uv run` prefix — just `spotify-mcp login`.)

> `login` opens a real browser window on the machine it runs on and waits for the OAuth redirect to reach `127.0.0.1`. Run it on your own local machine — it won't work over SSH or in a remote/headless sandbox with no browser to open.

**`SPOTIFY_CLIENT_ID`**: [create a Spotify app](https://developer.spotify.com/dashboard) → add `http://127.0.0.1:8000/auth/callback` as a Redirect URI → check **Web API** → copy the Client ID (no secret needed, this uses PKCE) → paste into `.env`.

**Register it with your MCP host:**

**Claude Code** — from the repo root:

```bash
claude mcp add spotify-mcp -- uv run --directory "$(pwd)" spotify-mcp
```

**Other hosts** (Claude Desktop, etc.) — add to the host's MCP config:

```json
{
  "mcpServers": {
    "spotify-mcp": {
      "command": "uv",
      "args": ["run", "--directory", "/absolute/path/to/spotify-mcp", "spotify-mcp"]
    }
  }
}
```

## Testing

```bash
uv run pytest
```

Tests run against an isolated, throwaway SQLite database and mocked Spotify API responses — they never touch a real Spotify account or the local `spotify_mcp.db`. See [journal entry 11](journal/11-test-suite.md) for details.

## Linting and formatting

```bash
uv run ruff check .      # lint
uv run ruff format .     # format
uv run mypy              # type check
```

Both run in CI (see the badge above). To run them automatically before each commit:

```bash
uv run pre-commit install
```

## License

[MIT](LICENSE) — see the license file for details.

## Author

Built by [Paco Molina](https://pacomolina.dev).

TDQS

A3.6/5.0

Scored across 27 tools

Disambiguation4/5

Most tools clearly target a distinct resource and action: devices, playlists, playback modes, liked songs, and search are easy to tell apart. The only minor overlaps are resume vs activate_device (both can start playback) and add_tracks vs queue_track, but their targets are clearly described.

Naming Consistency3/5

Snake_case is used consistently and many tools follow a verb_noun pattern such as list_devices, create_user_playlist, and unlike_tracks. However, several tools are noun or state phrases instead of commands (playlist_tracks, liked_songs, top_tracks, repeat_mode), and similar operations use different verbs like list_user_playlists vs find_playlists.

Tool Count3/5

27 tools is heavy and just past the 25 threshold, but Spotify's domain spans playback, playlists, library, and search, so most tools have a distinct purpose. A few tools like ping and the many single-purpose playback controls add bulk and could be consolidated.

Completeness3/5

Core workflows are covered: searching tracks, creating and editing playlists, controlling playback, and viewing listening history. Obvious gaps include no playlist update/delete/reorder, no ability to play a specific playlist or album context, and no artist/album search, so some lifecycle flows dead-end.

Maintenance

ActivityMaintained
ResponsivenessNo issues